diff --git a/README.md b/README.md
index 5730cac4..09955980 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 00000000..819d1bb5
--- /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 ed841cb8..9d6b9d83 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 00000000..fce75e80
--- /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 e17ab033..e08650c6 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 53bb146f..773b0100 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 00000000..7ba76162
--- /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 00000000..e69de29b
diff --git a/mesop/components/accordion/accordion.ng.html b/mesop/components/accordion/accordion.ng.html
new file mode 100644
index 00000000..0e6d5e8a
--- /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 00000000..717d0c3d
--- /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 00000000..02ec3f10
--- /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 00000000..9466a15d
--- /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 00000000..266b799e
--- /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 00000000..e69de29b
diff --git a/mesop/components/expansion_panel/e2e/BUILD b/mesop/components/expansion_panel/e2e/BUILD
new file mode 100644
index 00000000..f77d6e0f
--- /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 00000000..822f33a0
--- /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 00000000..a4541145
--- /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 00000000..0e99b257
--- /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 00000000..458892d4
--- /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 00000000..a5b3cd2a
--- /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 00000000..ace96041
--- /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 00000000..a2d9c117
--- /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 00000000..3d6cffd0
--- /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 00000000..d37985ae
--- /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 00000000..38c5da66
--- /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 53ae1675..b4cadc54 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 fcb3dec7..f01fe854 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 6a9e5260..1745f91e 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 d03def78..49ea8e38 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 34673ba1..ad4fc0db 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 9091faeb..03ed3fdb 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