diff --git a/CHANGELOG.md b/CHANGELOG.md
index f828fc612..02fa380cc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -21,6 +21,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* `playwright.controller.InputActionButton` gains a `expect_icon()` method. As a result, the already existing `expect_label()` no longer includes the icon. (#2020)
+### Changes
+
+* `express.ui.insert_accordion_panel()`'s function signature has changed to be more ergonomic. Now you can pass the `panel_title` and `panel_contents` directly instead of `ui.hold()`ing the `ui.accordion_panel()` context manager. (#2042)
+
### Improvements
* Improved the styling and readability of markdown tables rendered by `ui.Chat()` and `ui.MarkdownStream()`. (#1973)
diff --git a/shiny/api-examples/insert_accordion_panel/app-express.py b/shiny/api-examples/insert_accordion_panel/app-express.py
index 1de832962..cac1f7ee5 100644
--- a/shiny/api-examples/insert_accordion_panel/app-express.py
+++ b/shiny/api-examples/insert_accordion_panel/app-express.py
@@ -1,20 +1,21 @@
import random
-from shiny import reactive, ui
-from shiny.express import input
-
-
-def make_panel(letter):
- return ui.accordion_panel(
- f"Section {letter}", f"Some narrative for section {letter}"
- )
-
+from shiny import reactive
+from shiny.express import input, ui
ui.input_action_button("add_panel", "Add random panel", class_="mt-3 mb-3")
-ui.accordion(*[make_panel(letter) for letter in "ABCDE"], id="acc", multiple=True)
+
+with ui.accordion(id="acc", multiple=True):
+ for letter in "ABCDE":
+ with ui.accordion_panel(f"Section {letter}"):
+ f"Some narrative for section {letter}"
@reactive.effect
@reactive.event(input.add_panel)
def _():
- ui.insert_accordion_panel("acc", make_panel(str(random.randint(0, 10000))))
+ ui.insert_accordion_panel(
+ "acc",
+ f"Section {random.randint(0, 10000)}",
+ f"Some narrative for section {random.randint(0, 10000)}",
+ )
diff --git a/shiny/express/ui/__init__.py b/shiny/express/ui/__init__.py
index 4c3d61967..c78aa106e 100644
--- a/shiny/express/ui/__init__.py
+++ b/shiny/express/ui/__init__.py
@@ -1,15 +1,12 @@
from __future__ import annotations
-
from htmltools import (
- TagList,
+ HTML,
Tag,
- TagChild,
TagAttrs,
TagAttrValue,
- tags,
- HTML,
- head_content,
+ TagChild,
+ TagList,
a,
br,
code,
@@ -21,116 +18,113 @@
h4,
h5,
h6,
+ head_content,
hr,
img,
p,
pre,
span,
strong,
-)
-
-from ...ui import (
- fill,
-)
-
-from ...ui import (
- busy_indicators,
+ tags,
)
from ...ui import (
AccordionPanel,
AnimationOptions,
CardItem,
+ Progress,
ShowcaseLayout,
Sidebar,
SliderStepArg,
SliderValueArg,
+ Theme,
ValueBoxTheme,
+ bind_task_button,
brush_opts,
+ busy_indicators,
click_opts,
dblclick_opts,
+ fill,
help_text,
hover_opts,
include_css,
include_js,
- input_bookmark_button,
input_action_button,
input_action_link,
+ input_bookmark_button,
input_checkbox,
input_checkbox_group,
- input_switch,
- input_radio_buttons,
input_dark_mode,
input_date,
input_date_range,
input_file,
input_numeric,
input_password,
+ input_radio_buttons,
input_select,
input_selectize,
input_slider,
- bind_task_button,
+ input_switch,
input_task_button,
input_text,
input_text_area,
+ insert_ui,
+ js_eval,
+ markdown,
+ modal,
+ modal_button,
+ modal_remove,
+ modal_show,
+ nav_spacer,
+ navbar_options,
+ notification_remove,
+ notification_show,
panel_title,
- insert_accordion_panel,
remove_accordion_panel,
+ remove_nav_panel,
+ remove_ui,
update_accordion,
update_accordion_panel,
- update_sidebar,
update_action_button,
update_action_link,
update_checkbox,
- update_switch,
update_checkbox_group,
- update_radio_buttons,
update_dark_mode,
update_date,
update_date_range,
+ update_nav_panel,
+ update_navs,
update_numeric,
+ update_popover,
+ update_radio_buttons,
update_select,
update_selectize,
+ update_sidebar,
update_slider,
+ update_switch,
update_task_button,
update_text,
update_text_area,
- update_navs,
update_tooltip,
- update_popover,
- insert_ui,
- remove_ui,
- markdown,
- modal_button,
- modal,
- modal_show,
- modal_remove,
- notification_show,
- notification_remove,
- nav_spacer,
- navbar_options,
- remove_nav_panel,
- update_nav_panel,
- Progress,
- Theme,
value_box_theme,
- js_eval,
)
-
+from ...ui._chat import ChatExpress as Chat
+from ...ui._markdown_stream import (
+ ExpressMarkdownStream as MarkdownStream,
+)
from ._cm_components import (
- sidebar,
- layout_sidebar,
- layout_column_wrap,
- layout_columns,
+ accordion,
+ accordion_panel,
card,
card_body,
- card_header,
card_footer,
- accordion,
- accordion_panel,
- nav_panel,
+ card_header,
+ layout_column_wrap,
+ layout_columns,
+ layout_sidebar,
nav_control,
nav_menu,
+ nav_panel,
navset_bar,
navset_card_pill,
navset_card_tab,
@@ -140,32 +134,25 @@
navset_pill_list,
navset_tab,
navset_underline,
- value_box,
- panel_well,
+ panel_absolute,
panel_conditional,
panel_fixed,
- panel_absolute,
- tooltip,
+ panel_well,
popover,
+ sidebar,
+ tooltip,
+ value_box,
)
-
-from ...ui._chat import ChatExpress as Chat
-
-from ...ui._markdown_stream import (
- ExpressMarkdownStream as MarkdownStream,
-)
-
-from ._page import (
- page_opts,
-)
-
from ._hold import (
hold,
)
-
from ._insert import (
+ insert_accordion_panel,
insert_nav_panel,
)
+from ._page import (
+ page_opts,
+)
__all__ = (
# Imports from htmltools
diff --git a/shiny/express/ui/_insert.py b/shiny/express/ui/_insert.py
index 91546a453..d29ae6804 100644
--- a/shiny/express/ui/_insert.py
+++ b/shiny/express/ui/_insert.py
@@ -7,12 +7,82 @@
@ui.hold() pass the UI as a value without displaying it.
"""
-from typing import Literal, Optional
+from typing import Literal, Optional, Union
-from htmltools import TagChild
+from htmltools import TagAttrs, TagChild
from ..._docstring import add_example
from ...session import Session
+from ...types import MISSING, MISSING_TYPE
+
+
+@add_example()
+def insert_accordion_panel(
+ id: str,
+ panel_title: str,
+ *panel_contents: Union[TagChild, TagAttrs],
+ panel_value: Union[str, MISSING_TYPE, None] = MISSING,
+ panel_icon: TagChild = None,
+ target: Optional[str] = None,
+ position: Literal["after", "before"] = "after",
+ session: Optional[Session] = None,
+) -> None:
+ """
+ Insert an accordion panel into an existing accordion.
+
+ Parameters
+ ----------
+ id
+ A string that matches an existing :func:`~shiny.ui.express.accordion`'s `id`.
+ panel_title
+ The title to appear in the panel header.
+ panel_contents
+ UI elements for the panel's body. Can also be a dict of tag attributes for the
+ body's HTML container.
+ panel_value
+ A character string that uniquely identifies this panel. If `MISSING`, the
+ `title` will be used.
+ panel_icon
+ A :class:`~htmltools.TagChild` which is positioned just before the `title`.
+ target
+ The `value` of an existing panel to insert next to.
+ position
+ Should `panel` be added before or after the target? When `target=None`,
+ `"after"` will append after the last panel and `"before"` will prepend before
+ the first panel.
+ session
+ A Shiny session object (the default should almost always be used).
+
+ References
+ ----------
+ [Bootstrap Accordion](https://getbootstrap.com/docs/5.3/components/accordion/)
+
+ See Also
+ --------
+ * :func:`~shiny.ui.express.accordion`
+ * :func:`~shiny.ui.express.accordion_panel`
+ * :func:`~shiny.ui.express.update_accordion`
+ """
+
+ from ...ui import AccordionPanel, accordion_panel, insert_accordion_panel
+
+ if isinstance(panel_title, AccordionPanel):
+ # If the user passed an AccordionPanel, we can just use it as is.
+ # This isn't recommended, but we support it for backwards compatibility
+ # with the old API.
+ panel = panel_title
+ else:
+ panel = accordion_panel(
+ panel_title, *panel_contents, value=panel_value, icon=panel_icon
+ )
+
+ insert_accordion_panel(
+ id=id,
+ panel=panel,
+ target=target,
+ position=position,
+ session=session,
+ )
@add_example()
diff --git a/shiny/ui/_accordion.py b/shiny/ui/_accordion.py
index 2201b5eba..5d4ca72b5 100644
--- a/shiny/ui/_accordion.py
+++ b/shiny/ui/_accordion.py
@@ -315,17 +315,17 @@ def accordion_panel(
Parameters
----------
title
- A title to appear in the :func:`~shiny.ui.accordion_panel`'s header.
+ A title to appear in the panel's header.
*args
- Contents to the accordion panel body. Or tag attributes that are supplied to the
- returned :class:`~htmltools.Tag` object.
+ UI elements for the panel's body. Can also be a dict of tag attributes for the
+ body's HTML container.
value
A character string that uniquely identifies this panel. If `MISSING`, the
`title` will be used.
icon
- A :class:`~htmltools.Tag` which is positioned just before the `title`.
+ A :class:`~htmltools.TagChild` which is positioned just before the `title`.
**kwargs
- Tag attributes to the `accordion-body` div Tag.
+ Tag attributes for the body's HTML container.
Returns
-------
diff --git a/tests/playwright/shiny/bugs/1802-shiny-express-ui-accordion-panel-doesnt-work/app-express.py b/tests/playwright/shiny/bugs/1802-shiny-express-ui-accordion-panel-doesnt-work/app-express.py
new file mode 100644
index 000000000..296643fe9
--- /dev/null
+++ b/tests/playwright/shiny/bugs/1802-shiny-express-ui-accordion-panel-doesnt-work/app-express.py
@@ -0,0 +1,40 @@
+import datetime
+
+from shiny import reactive
+from shiny.express import input, ui
+
+with ui.accordion(id="my_accordion"):
+ with ui.accordion_panel(title="About"):
+ "This is a simple Shiny app."
+
+ with ui.accordion_panel(title="Panel 1"):
+ "Some initial content for Panel 1."
+
+ with ui.accordion_panel(title="Panel 2", value="panel_2_val"):
+ "Some initial content for Panel 2."
+
+ui.input_action_button("update_button", "Update Panel 2")
+ui.input_action_button("add_panel_button", "Add New Panel")
+
+panel_counter = reactive.value(3)
+
+
+@reactive.effect
+@reactive.event(input.update_button)
+def _():
+ new_content = f"Content updated at: {datetime.datetime.now().strftime('%H:%M:%S')}"
+ ui.update_accordion_panel(
+ "my_accordion", "panel_2_val", new_content, title="Panel 2 (Updated)"
+ )
+
+
+@reactive.effect
+@reactive.event(input.add_panel_button)
+def _():
+ current_count = panel_counter.get()
+ panel_counter.set(current_count + 1)
+ ui.insert_accordion_panel(
+ "my_accordion",
+ f"Panel {current_count}",
+ f"This is dynamically added panel {current_count}, created at {datetime.datetime.now().strftime('%H:%M:%S')}",
+ )
diff --git a/tests/playwright/shiny/bugs/1802-shiny-express-ui-accordion-panel-doesnt-work/test_app_express.py b/tests/playwright/shiny/bugs/1802-shiny-express-ui-accordion-panel-doesnt-work/test_app_express.py
new file mode 100644
index 000000000..b5eb120f9
--- /dev/null
+++ b/tests/playwright/shiny/bugs/1802-shiny-express-ui-accordion-panel-doesnt-work/test_app_express.py
@@ -0,0 +1,49 @@
+from playwright.sync_api import Page
+
+from shiny.playwright import controller
+from shiny.pytest import create_app_fixture
+from shiny.run import ShinyAppProc
+
+app = create_app_fixture(["app-express.py"])
+
+
+def test_accordion_and_buttons(page: Page, app: ShinyAppProc) -> None:
+ page.goto(app.url)
+
+ accordion = controller.Accordion(page, "my_accordion")
+ update_button = controller.InputActionButton(page, "update_button")
+ add_panel_button = controller.InputActionButton(page, "add_panel_button")
+
+ accordion.expect_panels(["About", "Panel 1", "panel_2_val"])
+
+ about_panel = accordion.accordion_panel("About")
+ about_panel.expect_label("About")
+ about_panel.expect_body("This is a simple Shiny app.")
+
+ panel_1 = accordion.accordion_panel("Panel 1")
+ panel_1.expect_label("Panel 1")
+ panel_1.expect_body("Some initial content for Panel 1.")
+
+ panel_2 = accordion.accordion_panel("panel_2_val")
+ panel_2.expect_label("Panel 2")
+ panel_2.expect_body("Some initial content for Panel 2.")
+
+ update_button.expect_label("Update Panel 2")
+ update_button.click()
+
+ panel_2.expect_label("Panel 2 (Updated)")
+
+ add_panel_button.expect_label("Add New Panel")
+ add_panel_button.click()
+
+ accordion.expect_panels(["About", "Panel 1", "panel_2_val", "Panel 3"])
+
+ panel_3 = accordion.accordion_panel("Panel 3")
+ panel_3.expect_label("Panel 3")
+
+ add_panel_button.click()
+
+ accordion.expect_panels(["About", "Panel 1", "panel_2_val", "Panel 3", "Panel 4"])
+
+ panel_4 = accordion.accordion_panel("Panel 4")
+ panel_4.expect_label("Panel 4")
diff --git a/tests/playwright/shiny/components/accordion/app.py b/tests/playwright/shiny/components/accordion/app.py
index 5ee4d2b7a..f9b230cab 100644
--- a/tests/playwright/shiny/components/accordion/app.py
+++ b/tests/playwright/shiny/components/accordion/app.py
@@ -1,142 +1,147 @@
from __future__ import annotations
-from shiny import App, Inputs, Outputs, Session, reactive, render, req, ui
-
-
-def make_panel(letter: str) -> ui.AccordionPanel:
- return ui.accordion_panel(
- f"Section {letter}",
- f"Some narrative for section {letter}",
- )
-
-
-items = [make_panel(letter) for letter in "ABCD"]
-
-accordion = ui.accordion(*items, id="acc")
-app_ui = ui.page_fluid(
- ui.tags.div(
- ui.input_action_button("toggle_b", "Open/Close B"),
- ui.input_action_button("open_all", "Open All"),
- ui.input_action_button("close_all", "Close All"),
- ui.input_action_button("alternate", "Alternate"),
- ui.input_action_button("toggle_efg", "Add/Remove EFG"),
- ui.input_action_button("toggle_updates", "Add/Remove Updates"),
- class_="d-flex",
- ),
- ui.output_text_verbatim("acc_txt", placeholder=True),
- accordion,
-)
-
-
-def server(input: Inputs, output: Outputs, session: Session) -> None:
- @reactive.calc
- def acc() -> list[str]:
- acc_val: list[str] | None = input.acc()
- if acc_val is None:
- acc_val = []
- return acc_val
-
- @reactive.effect
- def _():
- req(input.toggle_b())
+from shiny import reactive, render
+from shiny.express import input, ui
- with reactive.isolate():
- if "Section B" in acc():
- ui.update_accordion_panel("acc", "Section B", show=False)
- else:
- ui.update_accordion_panel("acc", "Section B", show=True)
-
- @reactive.effect
- def _():
- req(input.open_all())
- ui.update_accordion("acc", show=True)
-
- @reactive.effect
- def _():
- req(input.close_all())
- ui.update_accordion("acc", show=False)
-
- has_efg = False
- has_alternate = True
- has_updates = False
-
- @reactive.effect
- def _():
- req(input.alternate())
-
- sections = [
- "updated_section_a" if has_updates else "Section A",
- "Section B",
- "Section C",
- "Section D",
- ]
- if has_efg:
- sections.extend(["Section E", "Section F", "Section G"])
-
- nonlocal has_alternate
- val = int(has_alternate)
- sections = [section for i, section in enumerate(sections) if i % 2 == val]
- ui.update_accordion("acc", show=sections)
- has_alternate = not has_alternate
-
- @reactive.effect
- def _():
- req(input.toggle_efg())
-
- nonlocal has_efg
- if has_efg:
- ui.remove_accordion_panel("acc", ["Section E", "Section F", "Section G"])
- else:
- ui.insert_accordion_panel("acc", make_panel("E"), "Section D")
- ui.insert_accordion_panel("acc", make_panel("F"), "Section E")
- ui.insert_accordion_panel("acc", make_panel("G"), "Section F")
-
- has_efg = not has_efg
-
- @reactive.effect
- def _():
- req(input.toggle_updates())
-
- nonlocal has_updates
- if has_updates:
- ui.update_accordion_panel(
- "acc",
- "updated_section_a",
- "Some narrative for section A",
- title="Section A",
- value="Section A",
- icon="",
- )
- else:
- with reactive.isolate():
- # print(acc())
- if "Section A" not in acc():
- ui.notification_show("Opening Section A", duration=2)
- ui.update_accordion_panel("acc", "Section A", show=True)
- ui.update_accordion_panel(
- "acc",
- "Section A",
- "Updated body",
- value="updated_section_a",
- title=ui.tags.h3("Updated title"),
- icon=ui.tags.div(
- "Look! An icon! -->",
- ui.HTML(
- """\
-
- """
- ),
- ),
- )
- has_updates = not has_updates
+def make_panel_title(letter: str) -> str:
+ return f"Section {letter}"
+
+
+def make_panel_content(letter: str) -> str:
+ return f"Some narrative for section {letter}"
+
+
+with ui.tags.div(class_="d-flex"):
+ ui.input_action_button("toggle_b", "Open/Close B")
+ ui.input_action_button("open_all", "Open All")
+ ui.input_action_button("close_all", "Close All")
+ ui.input_action_button("alternate", "Alternate")
+ ui.input_action_button("toggle_efg", "Add/Remove EFG")
+ ui.input_action_button("toggle_updates", "Add/Remove Updates")
+
- @render.text
- def acc_txt():
- return f"input.acc(): {input.acc()}"
+@render.text
+def acc_txt():
+ return f"input.acc(): {input.acc()}"
-app = App(app_ui, server)
+with ui.accordion(id="acc"):
+ for letter in "ABCD":
+ with ui.accordion_panel(f"Section {letter}"):
+ f"Some narrative for section {letter}"
+
+
+@reactive.calc
+def acc() -> list[str]:
+ acc_val: list[str] | None = input.acc()
+ if acc_val is None:
+ acc_val = []
+ return acc_val
+
+
+@reactive.effect
+@reactive.event(input.toggle_b)
+def _():
+ with reactive.isolate():
+ if "Section B" in acc():
+ ui.update_accordion_panel("acc", "Section B", show=False)
+ else:
+ ui.update_accordion_panel("acc", "Section B", show=True)
+
+
+@reactive.effect
+@reactive.event(input.open_all)
+def _():
+ ui.update_accordion("acc", show=True)
+
+
+@reactive.effect
+@reactive.event(input.close_all)
+def _():
+ ui.update_accordion("acc", show=False)
+
+
+has_efg = False
+has_alternate = True
+has_updates = False
+
+
+@reactive.effect
+@reactive.event(input.alternate)
+def _():
+ sections = [
+ "updated_section_a" if has_updates else "Section A",
+ "Section B",
+ "Section C",
+ "Section D",
+ ]
+ if has_efg:
+ sections.extend(["Section E", "Section F", "Section G"])
+
+ global has_alternate
+ val = int(has_alternate)
+ sections = [section for i, section in enumerate(sections) if i % 2 == val]
+ ui.update_accordion("acc", show=sections)
+ has_alternate = not has_alternate
+
+
+@reactive.effect
+@reactive.event(input.toggle_efg)
+def _():
+ global has_efg
+ if has_efg:
+ ui.remove_accordion_panel("acc", ["Section E", "Section F", "Section G"])
+ else:
+ ui.insert_accordion_panel(
+ "acc", make_panel_title("E"), make_panel_content("E"), target="Section D"
+ )
+ ui.insert_accordion_panel(
+ "acc", make_panel_title("F"), make_panel_content("F"), target="Section E"
+ )
+ ui.insert_accordion_panel(
+ "acc", make_panel_title("G"), make_panel_content("G"), target="Section F"
+ )
+
+ has_efg = not has_efg
+
+
+@reactive.effect
+@reactive.event(input.toggle_updates)
+def _():
+ global has_updates
+ if has_updates:
+ ui.update_accordion_panel(
+ "acc",
+ "updated_section_a",
+ "Some narrative for section A",
+ title="Section A",
+ value="Section A",
+ icon="",
+ )
+ else:
+ with reactive.isolate():
+ # print(acc())
+ if "Section A" not in acc():
+ ui.notification_show("Opening Section A", duration=2)
+ ui.update_accordion_panel("acc", "Section A", show=True)
+ ui.update_accordion_panel(
+ "acc",
+ "Section A",
+ "Updated body",
+ value="updated_section_a",
+ title=ui.tags.h3("Updated title"),
+ icon=ui.tags.div(
+ "Look! An icon! -->",
+ ui.HTML(
+ """\
+
+ """
+ ),
+ ),
+ )
+
+ has_updates = not has_updates
diff --git a/tests/playwright/shiny/components/accordion/test_accordion.py b/tests/playwright/shiny/components/accordion/test_accordion.py
index f2d366c4b..18c14febb 100644
--- a/tests/playwright/shiny/components/accordion/test_accordion.py
+++ b/tests/playwright/shiny/components/accordion/test_accordion.py
@@ -12,7 +12,7 @@ def test_accordion(page: Page, local_app: ShinyAppProc) -> None:
acc = controller.Accordion(page, "acc")
acc_panel_A = acc.accordion_panel("Section A")
- output_txt_verbatim = controller.OutputTextVerbatim(page, "acc_txt")
+ output_txt_verbatim = controller.OutputText(page, "acc_txt")
alternate_button = controller.InputActionButton(page, "alternate")
open_all_button = controller.InputActionButton(page, "open_all")
close_all_button = controller.InputActionButton(page, "close_all")