From 96a47bb3efd681df1e167bbeb91a53b91f623c43 Mon Sep 17 00:00:00 2001 From: Richard To Date: Tue, 5 Nov 2024 09:57:43 -0800 Subject: [PATCH] Add expansion panel component. (#1089) * Add expansion panel component. Also adds support for the accordion component which uses multiple grouped expansion panels. The accordion behavior needs to be manually implemented through event handlers on the expansion panels. Closes #1081 --- README.md | 2 +- demo/expansion_panel.py | 165 ++++++++++++++++++ demo/main.py | 2 + docs/components/expansion-panel.md | 21 +++ mesop/BUILD | 2 + mesop/__init__.py | 7 + mesop/components/accordion/BUILD | 9 + mesop/components/accordion/__init__.py | 0 mesop/components/accordion/accordion.ng.html | 3 + mesop/components/accordion/accordion.proto | 7 + mesop/components/accordion/accordion.py | 28 +++ mesop/components/accordion/accordion.ts | 29 +++ mesop/components/expansion_panel/BUILD | 17 ++ mesop/components/expansion_panel/__init__.py | 0 mesop/components/expansion_panel/e2e/BUILD | 13 ++ .../expansion_panel/e2e/__init__.py | 3 + .../expansion_panel/e2e/accordion_app.py | 70 ++++++++ .../e2e/expansion_panel_app.py | 49 ++++++ .../e2e/expansion_panel_test.ts | 91 ++++++++++ .../e2e/multi_accordion_app.py | 84 +++++++++ .../expansion_panel/expansion_panel.ng.html | 18 ++ .../expansion_panel/expansion_panel.proto | 13 ++ .../expansion_panel/expansion_panel.py | 79 +++++++++ .../expansion_panel/expansion_panel.scss | 4 + .../expansion_panel/expansion_panel.ts | 84 +++++++++ mesop/example_index.py | 1 + mesop/examples/BUILD | 1 + mesop/web/src/app/styles.scss | 39 +++++ mesop/web/src/component_renderer/BUILD | 2 + .../component_renderer/type_to_component.ts | 4 + mkdocs.yml | 1 + 31 files changed, 847 insertions(+), 1 deletion(-) create mode 100644 demo/expansion_panel.py create mode 100644 docs/components/expansion-panel.md create mode 100644 mesop/components/accordion/BUILD create mode 100644 mesop/components/accordion/__init__.py create mode 100644 mesop/components/accordion/accordion.ng.html create mode 100644 mesop/components/accordion/accordion.proto create mode 100644 mesop/components/accordion/accordion.py create mode 100644 mesop/components/accordion/accordion.ts create mode 100644 mesop/components/expansion_panel/BUILD create mode 100644 mesop/components/expansion_panel/__init__.py create mode 100644 mesop/components/expansion_panel/e2e/BUILD create mode 100644 mesop/components/expansion_panel/e2e/__init__.py create mode 100644 mesop/components/expansion_panel/e2e/accordion_app.py create mode 100644 mesop/components/expansion_panel/e2e/expansion_panel_app.py create mode 100644 mesop/components/expansion_panel/e2e/expansion_panel_test.ts create mode 100644 mesop/components/expansion_panel/e2e/multi_accordion_app.py create mode 100644 mesop/components/expansion_panel/expansion_panel.ng.html create mode 100644 mesop/components/expansion_panel/expansion_panel.proto create mode 100644 mesop/components/expansion_panel/expansion_panel.py create mode 100644 mesop/components/expansion_panel/expansion_panel.scss create mode 100644 mesop/components/expansion_panel/expansion_panel.ts diff --git a/README.md b/README.md index 5730cac4d..09955980f 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Mesop is a Python-based UI framework that allows you to rapidly build web apps l ## Write your first Mesop app in less than 10 lines of code... -[Demo app](https://google.github.io/mesop/demo/?demo=text_to_text) +[Demo app](https://google.github.io/mesop/demo/?demo=text_io) ```python import time diff --git a/demo/expansion_panel.py b/demo/expansion_panel.py new file mode 100644 index 000000000..819d1bb5c --- /dev/null +++ b/demo/expansion_panel.py @@ -0,0 +1,165 @@ +from dataclasses import field + +import mesop as me + + +@me.stateclass +class State: + normal_accordion: dict[str, bool] = field( + default_factory=lambda: {"pie": True, "donut": False, "icecream": False} + ) + multi_accordion: dict[str, bool] = field( + default_factory=lambda: {"pie": False, "donut": False, "icecream": False} + ) + + +def load(e: me.LoadEvent): + me.set_theme_mode("system") + + +@me.page( + on_load=load, + security_policy=me.SecurityPolicy( + allowed_iframe_parents=["https://google.github.io"] + ), + path="/expansion_panel", +) +def app(): + state = me.state(State) + with me.box( + style=me.Style( + display="flex", + flex_direction="column", + gap=15, + margin=me.Margin.all(15), + max_width=500, + ) + ): + me.text("Normal Accordion", type="headline-5") + with me.accordion(): + with me.expansion_panel( + key="pie", + title="Pie", + description="Type of snack", + icon="pie_chart", + disabled=False, + expanded=state.normal_accordion["pie"], + hide_toggle=False, + on_toggle=on_accordion_toggle, + ): + me.text( + "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla sed augue ultricies, laoreet nunc eget, ultricies augue. In ornare bibendum mauris vel sodales. Donec ut interdum felis. Nulla facilisi. Morbi a laoreet turpis, sed posuere arcu. Nam nisi neque, molestie vitae euismod eu, sollicitudin eu lectus. Pellentesque orci metus, finibus id faucibus et, ultrices quis dui. Duis in augue ac metus tristique lacinia." + ) + + with me.expansion_panel( + key="donut", + title="Donut", + description="Type of breakfast", + icon="donut_large", + disabled=False, + expanded=state.normal_accordion["donut"], + hide_toggle=False, + on_toggle=on_accordion_toggle, + ): + me.text( + "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla sed augue ultricies, laoreet nunc eget, ultricies augue. In ornare bibendum mauris vel sodales. Donec ut interdum felis. Nulla facilisi. Morbi a laoreet turpis, sed posuere arcu. Nam nisi neque, molestie vitae euismod eu, sollicitudin eu lectus. Pellentesque orci metus, finibus id faucibus et, ultrices quis dui. Duis in augue ac metus tristique lacinia." + ) + + with me.expansion_panel( + key="icecream", + title="Ice cream", + description="Type of dessert", + icon="icecream", + disabled=False, + expanded=state.normal_accordion["icecream"], + hide_toggle=False, + on_toggle=on_accordion_toggle, + ): + me.text( + "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla sed augue ultricies, laoreet nunc eget, ultricies augue. In ornare bibendum mauris vel sodales. Donec ut interdum felis. Nulla facilisi. Morbi a laoreet turpis, sed posuere arcu. Nam nisi neque, molestie vitae euismod eu, sollicitudin eu lectus. Pellentesque orci metus, finibus id faucibus et, ultrices quis dui. Duis in augue ac metus tristique lacinia." + ) + + me.text("Multi Accordion", type="headline-5") + with me.box( + style=me.Style(display="flex", gap=20, margin=me.Margin(bottom=15)), + ): + me.button( + label="Open All", type="flat", on_click=on_multi_accordion_open_all + ) + me.button( + label="Close All", type="flat", on_click=on_multi_accordion_close_all + ) + + with me.accordion(): + with me.expansion_panel( + key="pie", + title="Pie", + description="Type of snack", + icon="pie_chart", + expanded=state.multi_accordion["pie"], + on_toggle=on_multi_accordion_toggle, + ): + me.text( + "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla sed augue ultricies, laoreet nunc eget, ultricies augue. In ornare bibendum mauris vel sodales. Donec ut interdum felis. Nulla facilisi. Morbi a laoreet turpis, sed posuere arcu. Nam nisi neque, molestie vitae euismod eu, sollicitudin eu lectus. Pellentesque orci metus, finibus id faucibus et, ultrices quis dui. Duis in augue ac metus tristique lacinia." + ) + + with me.expansion_panel( + key="donut", + title="Donut", + description="Type of breakfast", + icon="donut_large", + expanded=state.multi_accordion["donut"], + on_toggle=on_multi_accordion_toggle, + ): + me.text( + "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla sed augue ultricies, laoreet nunc eget, ultricies augue. In ornare bibendum mauris vel sodales. Donec ut interdum felis. Nulla facilisi. Morbi a laoreet turpis, sed posuere arcu. Nam nisi neque, molestie vitae euismod eu, sollicitudin eu lectus. Pellentesque orci metus, finibus id faucibus et, ultrices quis dui. Duis in augue ac metus tristique lacinia." + ) + + with me.expansion_panel( + key="icecream", + title="Ice cream", + description="Type of dessert", + icon="icecream", + expanded=state.multi_accordion["icecream"], + on_toggle=on_multi_accordion_toggle, + ): + me.text( + "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla sed augue ultricies, laoreet nunc eget, ultricies augue. In ornare bibendum mauris vel sodales. Donec ut interdum felis. Nulla facilisi. Morbi a laoreet turpis, sed posuere arcu. Nam nisi neque, molestie vitae euismod eu, sollicitudin eu lectus. Pellentesque orci metus, finibus id faucibus et, ultrices quis dui. Duis in augue ac metus tristique lacinia." + ) + + me.text("Expansion Panel", type="headline-5") + + with me.expansion_panel( + key="pie", + title="Pie", + description="Type of snack", + icon="pie_chart", + ): + me.text( + "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla sed augue ultricies, laoreet nunc eget, ultricies augue. In ornare bibendum mauris vel sodales. Donec ut interdum felis. Nulla facilisi. Morbi a laoreet turpis, sed posuere arcu. Nam nisi neque, molestie vitae euismod eu, sollicitudin eu lectus. Pellentesque orci metus, finibus id faucibus et, ultrices quis dui. Duis in augue ac metus tristique lacinia." + ) + + +def on_accordion_toggle(e: me.ExpansionPanelToggleEvent): + """Implements accordion behavior where only one panel can be open at a time""" + state = me.state(State) + state.normal_accordion = {"pie": False, "donut": False, "icecream": False} + state.normal_accordion[e.key] = e.opened + + +def on_multi_accordion_toggle(e: me.ExpansionPanelToggleEvent): + """Implements accordion behavior where multiple panels can be open at a time""" + state = me.state(State) + state.multi_accordion[e.key] = e.opened + + +def on_multi_accordion_open_all(e: me.ClickEvent): + state = me.state(State) + for key in state.multi_accordion: + state.multi_accordion[key] = True + + +def on_multi_accordion_close_all(e: me.ClickEvent): + state = me.state(State) + for key in state.multi_accordion: + state.multi_accordion[key] = False diff --git a/demo/main.py b/demo/main.py index ed841cb86..9d6b9d83a 100644 --- a/demo/main.py +++ b/demo/main.py @@ -39,6 +39,7 @@ import dialog as dialog import divider as divider import embed as embed +import expansion_panel as expansion_panel import fancy_chat as fancy_chat import feedback as feedback import form_billing as form_billing @@ -186,6 +187,7 @@ class Section: Example(name="badge"), Example(name="card"), Example(name="divider"), + Example(name="expansion_panel"), Example(name="icon"), Example(name="progress_bar"), Example(name="progress_spinner"), diff --git a/docs/components/expansion-panel.md b/docs/components/expansion-panel.md new file mode 100644 index 000000000..fce75e805 --- /dev/null +++ b/docs/components/expansion-panel.md @@ -0,0 +1,21 @@ +## Overview + +Expansion panel and is based on the [Angular Material expansion panel component](https://material.angular.io/components/expansion/overview). + +This is a useful component for showing a summary header which can be expanded into a more detailed card/panel. + +The expansion panels can also be grouped together to create an accordion. + +## Examples + + + +```python +--8<-- "demo/expansion_panel.py" +``` + +## API + +::: mesop.components.accordion.accordion.accordion +::: mesop.components.expansion_panel.expansion_panel.expansion_panel +::: mesop.components.expansion_panel.expansion_panel.ExpansionPanelToggleEvent diff --git a/mesop/BUILD b/mesop/BUILD index e17ab033a..e08650c69 100644 --- a/mesop/BUILD +++ b/mesop/BUILD @@ -23,6 +23,8 @@ py_library( deps = [ ":version", # REF(//scripts/scaffold_component.py):insert_component_import + "//mesop/components/accordion:py", + "//mesop/components/expansion_panel:py", "//mesop/components/card_header:py", "//mesop/components/card_actions:py", "//mesop/components/card_content:py", diff --git a/mesop/__init__.py b/mesop/__init__.py index 53bb146f5..773b0100b 100644 --- a/mesop/__init__.py +++ b/mesop/__init__.py @@ -34,6 +34,7 @@ from mesop.component_helpers.helper import ( slot as slot, ) +from mesop.components.accordion.accordion import accordion as accordion from mesop.components.audio.audio import audio as audio from mesop.components.autocomplete.autocomplete import ( AutocompleteEnterEvent as AutocompleteEnterEvent, @@ -102,6 +103,12 @@ from mesop.components.datepicker.datepicker import date_picker as date_picker from mesop.components.divider.divider import divider as divider from mesop.components.embed.embed import embed as embed +from mesop.components.expansion_panel.expansion_panel import ( + ExpansionPanelToggleEvent as ExpansionPanelToggleEvent, +) +from mesop.components.expansion_panel.expansion_panel import ( + expansion_panel as expansion_panel, +) from mesop.components.html.html import html as html from mesop.components.icon.icon import icon as icon from mesop.components.image.image import image as image diff --git a/mesop/components/accordion/BUILD b/mesop/components/accordion/BUILD new file mode 100644 index 000000000..7ba761624 --- /dev/null +++ b/mesop/components/accordion/BUILD @@ -0,0 +1,9 @@ +load("//mesop/components:defs.bzl", "mesop_component") + +package( + default_visibility = ["//build_defs:mesop_internal"], +) + +mesop_component( + name = "accordion", +) diff --git a/mesop/components/accordion/__init__.py b/mesop/components/accordion/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/mesop/components/accordion/accordion.ng.html b/mesop/components/accordion/accordion.ng.html new file mode 100644 index 000000000..0e6d5e8a7 --- /dev/null +++ b/mesop/components/accordion/accordion.ng.html @@ -0,0 +1,3 @@ + + + diff --git a/mesop/components/accordion/accordion.proto b/mesop/components/accordion/accordion.proto new file mode 100644 index 000000000..717d0c3d0 --- /dev/null +++ b/mesop/components/accordion/accordion.proto @@ -0,0 +1,7 @@ +syntax = "proto2"; + +package mesop.components.accordion; + +message AccordionType { + +} diff --git a/mesop/components/accordion/accordion.py b/mesop/components/accordion/accordion.py new file mode 100644 index 000000000..02ec3f104 --- /dev/null +++ b/mesop/components/accordion/accordion.py @@ -0,0 +1,28 @@ +import mesop.components.accordion.accordion_pb2 as accordion_pb +from mesop.component_helpers import ( + insert_composite_component, + register_native_component, +) + + +@register_native_component +def accordion( + *, + key: str | None = None, +): + """ + This function creates an accordion. + + This is more of a visual component. It is used to style a group of expansion panel + components in a unified and consistent way (as if they were one component -- i.e. an + accordion). + + The mechanics of an accordion that only allows one expansion panel to be open at a + time, must be implemented manually, but is easy to do with Mesop state and event + handlers. + """ + return insert_composite_component( + key=key, + type_name="accordion", + proto=accordion_pb.AccordionType(), + ) diff --git a/mesop/components/accordion/accordion.ts b/mesop/components/accordion/accordion.ts new file mode 100644 index 000000000..9466a15d1 --- /dev/null +++ b/mesop/components/accordion/accordion.ts @@ -0,0 +1,29 @@ +import {MatAccordion} from '@angular/material/expansion'; +import {Component, Input} from '@angular/core'; +import { + Key, + Type, +} from 'mesop/mesop/protos/ui_jspb_proto_pb/mesop/protos/ui_pb'; +import {AccordionType} from 'mesop/mesop/components/accordion/accordion_jspb_proto_pb/mesop/components/accordion/accordion_pb'; + +@Component({ + selector: 'mesop-accordion', + templateUrl: 'accordion.ng.html', + standalone: true, + imports: [MatAccordion], +}) +export class AccordionComponent { + @Input({required: true}) type!: Type; + @Input() key!: Key; + private _config!: AccordionType; + + ngOnChanges() { + this._config = AccordionType.deserializeBinary( + this.type.getValue() as unknown as Uint8Array, + ); + } + + config(): AccordionType { + return this._config; + } +} diff --git a/mesop/components/expansion_panel/BUILD b/mesop/components/expansion_panel/BUILD new file mode 100644 index 000000000..266b799e0 --- /dev/null +++ b/mesop/components/expansion_panel/BUILD @@ -0,0 +1,17 @@ +load("//mesop/components:defs.bzl", "mesop_component") +load("//build_defs:defaults.bzl", "sass_binary") + +package( + default_visibility = ["//build_defs:mesop_internal"], +) + +mesop_component( + name = "expansion_panel", + assets = [":expansion_panel.css"], +) + +sass_binary( + name = "styles", + src = "expansion_panel.scss", + sourcemap = False, +) diff --git a/mesop/components/expansion_panel/__init__.py b/mesop/components/expansion_panel/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/mesop/components/expansion_panel/e2e/BUILD b/mesop/components/expansion_panel/e2e/BUILD new file mode 100644 index 000000000..f77d6e0f5 --- /dev/null +++ b/mesop/components/expansion_panel/e2e/BUILD @@ -0,0 +1,13 @@ +load("//build_defs:defaults.bzl", "py_library") + +package( + default_visibility = ["//build_defs:mesop_examples"], +) + +py_library( + name = "e2e", + srcs = glob(["*.py"]), + deps = [ + "//mesop", + ], +) diff --git a/mesop/components/expansion_panel/e2e/__init__.py b/mesop/components/expansion_panel/e2e/__init__.py new file mode 100644 index 000000000..822f33a05 --- /dev/null +++ b/mesop/components/expansion_panel/e2e/__init__.py @@ -0,0 +1,3 @@ +from . import accordion_app as accordion_app +from . import expansion_panel_app as expansion_panel_app +from . import multi_accordion_app as multi_accordion_app diff --git a/mesop/components/expansion_panel/e2e/accordion_app.py b/mesop/components/expansion_panel/e2e/accordion_app.py new file mode 100644 index 000000000..a4541145e --- /dev/null +++ b/mesop/components/expansion_panel/e2e/accordion_app.py @@ -0,0 +1,70 @@ +from dataclasses import field + +import mesop as me + + +@me.stateclass +class State: + normal_accordion: dict[str, bool] = field( + default_factory=lambda: {"pie": True, "donut": False, "icecream": False} + ) + + +@me.page( + path="/components/expansion_panel/e2e/accordion_app", +) +def app(): + state = me.state(State) + with me.box( + style=me.Style( + display="flex", + flex_direction="column", + gap=15, + margin=me.Margin.all(15), + max_width=500, + ) + ): + me.text("Normal Accordion", type="headline-5") + with me.accordion(): + with me.expansion_panel( + key="pie", + title="Pie title", + description="Type of snack", + icon="pie_chart", + disabled=False, + expanded=state.normal_accordion["pie"], + hide_toggle=False, + on_toggle=on_accordion_toggle, + ): + me.text("Pie content.") + + with me.expansion_panel( + key="donut", + title="Donut title", + description="Type of breakfast", + icon="donut_large", + disabled=False, + expanded=state.normal_accordion["donut"], + hide_toggle=False, + on_toggle=on_accordion_toggle, + ): + me.text("Donut content.") + + with me.expansion_panel( + key="icecream", + title="Ice cream title", + description="Type of dessert", + icon="icecream", + disabled=False, + expanded=state.normal_accordion["icecream"], + hide_toggle=False, + on_toggle=on_accordion_toggle, + ): + me.text("Ice cream content.") + + +def on_accordion_toggle(e: me.ExpansionPanelToggleEvent): + """Implements accordion behavior where only one panel can be open at a time""" + state = me.state(State) + state.normal_accordion = {"pie": False, "donut": False, "icecream": False} + state.normal_accordion[e.key] = e.opened diff --git a/mesop/components/expansion_panel/e2e/expansion_panel_app.py b/mesop/components/expansion_panel/e2e/expansion_panel_app.py new file mode 100644 index 000000000..0e99b257b --- /dev/null +++ b/mesop/components/expansion_panel/e2e/expansion_panel_app.py @@ -0,0 +1,49 @@ +import mesop as me + + +@me.stateclass +class State: + opened: bool = False + + +@me.page(path="/components/expansion_panel/e2e/expansion_panel_app") +def app(): + state = me.state(State) + with me.box( + style=me.Style( + display="flex", + flex_direction="column", + gap=15, + margin=me.Margin.all(15), + max_width=500, + ) + ): + with me.expansion_panel( + title="Grapefruit title", + description="Type of fruit", + icon="nutrition", + disabled=False, + hide_toggle=False, + expanded=state.opened, + on_toggle=on_toggle, + ): + me.text("Grapefruit content.") + + with me.expansion_panel( + title="Pineapple title", + icon="nutrition", + disabled=True, + hide_toggle=False, + ): + me.text("Pineapple content.") + + with me.expansion_panel( + title="Cantalope title", + description="Type of fruit", + hide_toggle=True, + ): + me.text("Cantalope content.") + + +def on_toggle(e: me.ExpansionPanelToggleEvent): + me.state(State).opened = e.opened diff --git a/mesop/components/expansion_panel/e2e/expansion_panel_test.ts b/mesop/components/expansion_panel/e2e/expansion_panel_test.ts new file mode 100644 index 000000000..458892d45 --- /dev/null +++ b/mesop/components/expansion_panel/e2e/expansion_panel_test.ts @@ -0,0 +1,91 @@ +import {test, expect} from '@playwright/test'; + +test.describe('Expansion Panel', () => { + test('basic render', async ({page}) => { + await page.goto('/components/expansion_panel/e2e/expansion_panel_app'); + await expect(await page.getByText('Grapefruit content.')).toBeHidden(); + await page.getByText('Grapefruit title').click(); + await expect(await page.getByText('Grapefruit content.')).toBeVisible(); + await page.getByText('Grapefruit title').click(); + await expect(await page.getByText('Grapefruit content.')).toBeHidden(); + }); + + test('disabled panel', async ({page}) => { + await page.goto('/components/expansion_panel/e2e/expansion_panel_app'); + await expect( + await page.locator('[aria-disabled="true"]').textContent(), + ).toContain('Pineapple title'); + }); + + test('hidden toggle', async ({page}) => { + await page.goto('/components/expansion_panel/e2e/expansion_panel_app'); + await expect( + await page + .locator('[aria-disabled="false"] span.mat-content-hide-toggle') + .textContent(), + ).toContain('Cantalope title'); + }); +}); + +test.describe('Accordion (single expanded panel)', () => { + test('hidden toggle', async ({page}) => { + await page.goto('/components/expansion_panel/e2e/accordion_app'); + await expect(await page.getByText('Pie content.')).toBeVisible(); + await expect(await page.getByText('Donut content.')).toBeHidden(); + await expect(await page.getByText('Ice cream content.')).toBeHidden(); + + await page.getByText('Donut title').click(); + await expect(await page.getByText('Pie content.')).toBeHidden(); + await expect(await page.getByText('Donut content.')).toBeVisible(); + await expect(await page.getByText('Ice cream content.')).toBeHidden(); + + await page.getByText('Donut title').click(); + await expect(await page.getByText('Pie content.')).toBeHidden(); + await expect(await page.getByText('Donut content.')).toBeHidden(); + await expect(await page.getByText('Ice cream content.')).toBeHidden(); + + await page.getByText('Ice cream title').click(); + await expect(await page.getByText('Pie content.')).toBeHidden(); + await expect(await page.getByText('Donut content.')).toBeHidden(); + await expect(await page.getByText('Ice cream content.')).toBeVisible(); + }); +}); + +test.describe('Accordion (multiple expanded panels)', () => { + test('multiple expansions allowed', async ({page}) => { + await page.goto('/components/expansion_panel/e2e/multi_accordion_app'); + await expect(await page.getByText('Pie content.')).toBeHidden(); + await expect(await page.getByText('Donut content.')).toBeHidden(); + await expect(await page.getByText('Ice cream content.')).toBeHidden(); + + await page.getByText('Open All').click(); + await expect(await page.getByText('Pie content.')).toBeVisible(); + await expect(await page.getByText('Donut content.')).toBeVisible(); + await expect(await page.getByText('Ice cream content.')).toBeVisible(); + + await page.getByText('Close All').click(); + await expect(await page.getByText('Pie content.')).toBeHidden(); + await expect(await page.getByText('Donut content.')).toBeHidden(); + await expect(await page.getByText('Ice cream content.')).toBeHidden(); + + await page.getByText('Donut title').click(); + await expect(await page.getByText('Pie content.')).toBeHidden(); + await expect(await page.getByText('Donut content.')).toBeVisible(); + await expect(await page.getByText('Ice cream content.')).toBeHidden(); + + await page.getByText('Pie title').click(); + await expect(await page.getByText('Pie content.')).toBeVisible(); + await expect(await page.getByText('Donut content.')).toBeVisible(); + await expect(await page.getByText('Ice cream content.')).toBeHidden(); + + await page.getByText('Ice cream title').click(); + await expect(await page.getByText('Pie content.')).toBeVisible(); + await expect(await page.getByText('Donut content.')).toBeVisible(); + await expect(await page.getByText('Ice cream content.')).toBeVisible(); + + await page.getByText('Donut title').click(); + await expect(await page.getByText('Pie content.')).toBeVisible(); + await expect(await page.getByText('Donut content.')).toBeHidden(); + await expect(await page.getByText('Ice cream content.')).toBeVisible(); + }); +}); diff --git a/mesop/components/expansion_panel/e2e/multi_accordion_app.py b/mesop/components/expansion_panel/e2e/multi_accordion_app.py new file mode 100644 index 000000000..a5b3cd2ab --- /dev/null +++ b/mesop/components/expansion_panel/e2e/multi_accordion_app.py @@ -0,0 +1,84 @@ +from dataclasses import field + +import mesop as me + + +@me.stateclass +class State: + multi_accordion: dict[str, bool] = field( + default_factory=lambda: {"pie": False, "donut": False, "icecream": False} + ) + + +@me.page( + path="/components/expansion_panel/e2e/multi_accordion_app", +) +def app(): + state = me.state(State) + with me.box( + style=me.Style( + display="flex", + flex_direction="column", + gap=15, + margin=me.Margin.all(15), + max_width=500, + ) + ): + with me.box( + style=me.Style(display="flex", gap=20, margin=me.Margin(bottom=15)), + ): + me.button( + label="Open All", type="flat", on_click=on_multi_accordion_open_all + ) + me.button( + label="Close All", type="flat", on_click=on_multi_accordion_close_all + ) + + with me.accordion(): + with me.expansion_panel( + key="pie", + title="Pie title", + description="Type of snack", + icon="pie_chart", + expanded=state.multi_accordion["pie"], + on_toggle=on_multi_accordion_toggle, + ): + me.text("Pie content.") + + with me.expansion_panel( + key="donut", + title="Donut Title", + description="Type of breakfast", + icon="donut_large", + expanded=state.multi_accordion["donut"], + on_toggle=on_multi_accordion_toggle, + ): + me.text("Donut content.") + + with me.expansion_panel( + key="icecream", + title="Ice cream title", + description="Type of dessert", + icon="icecream", + expanded=state.multi_accordion["icecream"], + on_toggle=on_multi_accordion_toggle, + ): + me.text("Ice cream content.") + + +def on_multi_accordion_toggle(e: me.ExpansionPanelToggleEvent): + """Implements accordion behavior where multiple panels can be open at a time""" + state = me.state(State) + state.multi_accordion[e.key] = e.opened + + +def on_multi_accordion_open_all(e: me.ClickEvent): + state = me.state(State) + for key in state.multi_accordion: + state.multi_accordion[key] = True + + +def on_multi_accordion_close_all(e: me.ClickEvent): + state = me.state(State) + for key in state.multi_accordion: + state.multi_accordion[key] = False diff --git a/mesop/components/expansion_panel/expansion_panel.ng.html b/mesop/components/expansion_panel/expansion_panel.ng.html new file mode 100644 index 000000000..ace960410 --- /dev/null +++ b/mesop/components/expansion_panel/expansion_panel.ng.html @@ -0,0 +1,18 @@ + + + {{config().getTitle()}} + + {{config().getDescription()}} @if (config().getIcon()) { + {{config().getIcon()}} + } + + + + diff --git a/mesop/components/expansion_panel/expansion_panel.proto b/mesop/components/expansion_panel/expansion_panel.proto new file mode 100644 index 000000000..a2d9c117b --- /dev/null +++ b/mesop/components/expansion_panel/expansion_panel.proto @@ -0,0 +1,13 @@ +syntax = "proto2"; + +package mesop.components.expansion_panel; + +message ExpansionPanelType { + optional string title = 1; + optional string description = 2; + optional string icon = 3; + optional bool disabled = 4; + optional string expanded = 5; + optional bool hide_toggle = 6; + optional string on_toggle_handler_id = 7; +} diff --git a/mesop/components/expansion_panel/expansion_panel.py b/mesop/components/expansion_panel/expansion_panel.py new file mode 100644 index 000000000..3d6cffd01 --- /dev/null +++ b/mesop/components/expansion_panel/expansion_panel.py @@ -0,0 +1,79 @@ +from dataclasses import dataclass +from typing import Any, Callable + +import mesop.components.expansion_panel.expansion_panel_pb2 as expansion_panel_pb +from mesop.component_helpers import ( + Style, + insert_composite_component, + register_event_handler, + register_event_mapper, + register_native_component, +) +from mesop.events import MesopEvent + + +@dataclass(kw_only=True) +class ExpansionPanelToggleEvent(MesopEvent): + """Event representing the opening/closing of the expansion panel. + + Attributes: + opened: Whether the expansion panel is opened. + key (str): key of the component that emitted this event. + """ + + opened: bool + + +register_event_mapper( + ExpansionPanelToggleEvent, + lambda event, key: ExpansionPanelToggleEvent( + key=key.key, opened=event.bool_value + ), +) + + +@register_native_component +def expansion_panel( + *, + title: str, + description: str = "", + icon: str = "", + disabled: bool = False, + expanded: bool | None = None, + hide_toggle: bool = False, + on_toggle: Callable[[ExpansionPanelToggleEvent], Any] | None = None, + style: Style | None = None, + key: str | None = None, +): + """ + This function creates an expansion_panel. + + Args: + title: Title of the panel. + description: Optional brief description of the panel. + icon: Optional icon from https://fonts.google.com/icons. + disabled: Whether the panel is disabled. + expanded: Whether the toggle is expanded. Use `None` if you do not need to manage open/closed state. + hide_toggle: Whether to the toggle is shown. + on_toggle: Event fired when the expansion panel header is opened/closed. + style: Style for the component. + key: The component [key](../components/index.md#component-key). + """ + return insert_composite_component( + key=key, + type_name="expansion_panel", + style=style, + proto=expansion_panel_pb.ExpansionPanelType( + title=title, + description=description, + icon=icon, + disabled=disabled, + expanded=str(expanded), + hide_toggle=hide_toggle, + on_toggle_handler_id=register_event_handler( + on_toggle, event=ExpansionPanelToggleEvent + ) + if on_toggle + else "", + ), + ) diff --git a/mesop/components/expansion_panel/expansion_panel.scss b/mesop/components/expansion_panel/expansion_panel.scss new file mode 100644 index 000000000..d37985ae8 --- /dev/null +++ b/mesop/components/expansion_panel/expansion_panel.scss @@ -0,0 +1,4 @@ +.mat-expansion-panel-header-description { + justify-content: space-between; + align-items: center; +} diff --git a/mesop/components/expansion_panel/expansion_panel.ts b/mesop/components/expansion_panel/expansion_panel.ts new file mode 100644 index 000000000..38c5da660 --- /dev/null +++ b/mesop/components/expansion_panel/expansion_panel.ts @@ -0,0 +1,84 @@ +import {MatIconModule} from '@angular/material/icon'; +import {MatExpansionModule} from '@angular/material/expansion'; +import {Component, Input} from '@angular/core'; +import { + Style, + Key, + Type, + UserEvent, +} from 'mesop/mesop/protos/ui_jspb_proto_pb/mesop/protos/ui_pb'; +import {ExpansionPanelType} from 'mesop/mesop/components/expansion_panel/expansion_panel_jspb_proto_pb/mesop/components/expansion_panel/expansion_panel_pb'; +import {Channel} from '../../web/src/services/channel'; +import {formatStyle} from '../../web/src/utils/styles'; + +type ExpansionPanelEnabledState = 'None' | 'True' | 'False'; + +@Component({ + selector: 'mesop-expansion-panel', + templateUrl: 'expansion_panel.ng.html', + standalone: true, + styleUrl: 'expansion_panel.css', + imports: [MatExpansionModule, MatIconModule], +}) +export class ExpansionPanelComponent { + @Input({required: true}) type!: Type; + @Input() key!: Key; + @Input() style!: Style; + private _config!: ExpansionPanelType; + private initialPanelState: ExpansionPanelEnabledState = 'False'; + constructor(private readonly channel: Channel) {} + + ngOnChanges() { + this._config = ExpansionPanelType.deserializeBinary( + this.type.getValue() as unknown as Uint8Array, + ); + + if ( + this.initialPanelState !== + (this._config.getExpanded()! as ExpansionPanelEnabledState) + ) { + this.initialPanelState = + this._config.getExpanded()! as ExpansionPanelEnabledState; + } + } + + config(): ExpansionPanelType { + return this._config; + } + + expanded(): boolean | undefined { + if (this._config.getExpanded() === 'True') { + return true; + } + if (this._config.getExpanded() === 'False') { + return false; + } + return undefined; + } + + onPanelOpened(): void { + if (this.initialPanelState === 'True') { + return; + } + const userEvent = new UserEvent(); + userEvent.setHandlerId(this.config().getOnToggleHandlerId()!); + userEvent.setKey(this.key); + userEvent.setBoolValue(true); + this.channel.dispatch(userEvent); + } + + onPanelClosed(): void { + if (this.initialPanelState === 'False') { + return; + } + const userEvent = new UserEvent(); + userEvent.setHandlerId(this.config().getOnToggleHandlerId()!); + userEvent.setKey(this.key); + userEvent.setBoolValue(false); + this.channel.dispatch(userEvent); + } + + getStyle(): string { + return formatStyle(this.style); + } +} diff --git a/mesop/example_index.py b/mesop/example_index.py index 53ae16759..b4cadc54d 100644 --- a/mesop/example_index.py +++ b/mesop/example_index.py @@ -34,5 +34,6 @@ import mesop.components.datepicker.e2e as datepicker_e2e import mesop.components.date_range_picker.e2e as date_range_picker_e2e import mesop.components.button_toggle.e2e as button_toggle_e2e +import mesop.components.expansion_panel.e2e as expansion_panel_e2e import mesop.components.card.e2e as card_e2e # REF(//scripts/scaffold_component.py):insert_component_e2e_import_export diff --git a/mesop/examples/BUILD b/mesop/examples/BUILD index fcb3dec7d..f01fe8546 100644 --- a/mesop/examples/BUILD +++ b/mesop/examples/BUILD @@ -15,6 +15,7 @@ py_library( deps = [ "//demo", # REF(//scripts/scaffold_component.py):insert_component_e2e_import + "//mesop/components/expansion_panel/e2e", "//mesop/components/card/e2e", "//mesop/components/button_toggle/e2e", "//mesop/components/date_range_picker/e2e", diff --git a/mesop/web/src/app/styles.scss b/mesop/web/src/app/styles.scss index 6a9e52601..1745f91ee 100644 --- a/mesop/web/src/app/styles.scss +++ b/mesop/web/src/app/styles.scss @@ -277,6 +277,45 @@ mat-sidenav-content > component-renderer:first-child { display: inline; } +// We need to add custom mat-accordion styles since the expansion panels are wrapped +// by component-renderer which messes up some of the built-in Angular Material styles +// for the accordion. +.mat-accordion { + component-renderer .mat-expansion-panel:first-of-type, + component-renderer .mat-expansion-panel:last-of-type { + border-radius: 0; + } + + component-renderer:first-of-type .mat-expansion-panel { + border-top-right-radius: var(--mat-expansion-container-shape); + border-top-left-radius: var(--mat-expansion-container-shape); + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; + } + + component-renderer:last-of-type .mat-expansion-panel { + border-top-right-radius: 0; + border-top-left-radius: 0; + border-bottom-right-radius: var(--mat-expansion-container-shape); + border-bottom-left-radius: var(--mat-expansion-container-shape); + } + + component-renderer:first-of-type .mat-expansion-panel.mat-expanded { + margin-top: 0; + border-radius: var(--mat-expansion-container-shape); + } + + component-renderer:last-of-type .mat-expansion-panel.mat-expanded { + margin-bottom: 0; + border-radius: var(--mat-expansion-container-shape); + } + + component-renderer .mat-expansion-panel.mat-expanded { + margin: 16px 0; + border-radius: var(--mat-expansion-container-shape); + } +} + mesop-markdown { h1, h2, diff --git a/mesop/web/src/component_renderer/BUILD b/mesop/web/src/component_renderer/BUILD index d03def78f..49ea8e38f 100644 --- a/mesop/web/src/component_renderer/BUILD +++ b/mesop/web/src/component_renderer/BUILD @@ -17,6 +17,8 @@ ng_module( ]) + ["component_renderer.css"], deps = [ # REF(//scripts/scaffold_component.py):insert_component_import + "//mesop/components/accordion:ng", + "//mesop/components/expansion_panel:ng", "//mesop/components/card_header:ng", "//mesop/components/card_actions:ng", "//mesop/components/card_content:ng", diff --git a/mesop/web/src/component_renderer/type_to_component.ts b/mesop/web/src/component_renderer/type_to_component.ts index 34673ba12..ad4fc0db9 100644 --- a/mesop/web/src/component_renderer/type_to_component.ts +++ b/mesop/web/src/component_renderer/type_to_component.ts @@ -1,3 +1,5 @@ +import {AccordionComponent} from '../../../components/accordion/accordion'; +import {ExpansionPanelComponent} from '../../../components/expansion_panel/expansion_panel'; import {CardHeaderComponent} from '../../../components/card_header/card_header'; import {CardActionsComponent} from '../../../components/card_actions/card_actions'; import {CardContentComponent} from '../../../components/card_content/card_content'; @@ -63,6 +65,8 @@ export class UserDefinedComponent implements BaseComponent { } export const typeToComponent = { + 'accordion': AccordionComponent, + 'expansion_panel': ExpansionPanelComponent, 'card_header': CardHeaderComponent, 'card_actions': CardActionsComponent, 'card_content': CardContentComponent, diff --git a/mkdocs.yml b/mkdocs.yml index 9091faeb7..03ed3fdbf 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -76,6 +76,7 @@ nav: - Badge: components/badge.md - Card: components/card.md - Divider: components/divider.md + - Expansion panel: components/expansion-panel.md - Icon: components/icon.md - Progress bar: components/progress-bar.md - Progress spinner: components/progress-spinner.md