diff --git a/CHANGELOG.md b/CHANGELOG.md index d03de25e5..f828fc612 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### New features +* Added `ui.insert_nav_panel()`, `ui.remove_nav_panel()`, and `ui.update_nav_panel()` to support dynamic navigation. (#90) + * Added support for python 3.13. (#1711) * `ui.sidebar()` is now interactively resizable. (#2020) diff --git a/docs/_quartodoc-core.yml b/docs/_quartodoc-core.yml index 57b6cf86f..cb8cff5c5 100644 --- a/docs/_quartodoc-core.yml +++ b/docs/_quartodoc-core.yml @@ -84,6 +84,9 @@ quartodoc: - ui.navset_pill_list - ui.navset_hidden - ui.navbar_options + - ui.insert_nav_panel + - ui.remove_nav_panel + - ui.update_nav_panel - title: UI panels desc: Visually group together a section of UI components. contents: diff --git a/docs/_quartodoc-express.yml b/docs/_quartodoc-express.yml index 28d976969..a140abb5d 100644 --- a/docs/_quartodoc-express.yml +++ b/docs/_quartodoc-express.yml @@ -78,6 +78,9 @@ quartodoc: - express.ui.navset_pill_list - express.ui.navset_hidden - express.ui.navbar_options + - express.ui.insert_nav_panel + - express.ui.remove_nav_panel + - express.ui.update_nav_panel - title: Chat interface desc: Build a chatbot interface contents: diff --git a/shiny/api-examples/insert_nav_panel/app-core.py b/shiny/api-examples/insert_nav_panel/app-core.py new file mode 100644 index 000000000..cf4bdb383 --- /dev/null +++ b/shiny/api-examples/insert_nav_panel/app-core.py @@ -0,0 +1,52 @@ +from shiny import App, Inputs, Outputs, Session, reactive, ui + +app_ui = ui.page_sidebar( + ui.sidebar( + ui.input_action_button("add", "Add 'Dynamic' tab"), + ui.input_action_button("update_foo", "Add/Remove 'Foo' tab"), + ), + ui.navset_tab( + ui.nav_panel("Hello", "This is the hello tab", value="Hello"), + ui.nav_panel("Foo", "This is the Foo tab", value="Foo"), + ui.nav_menu( + "Static", + ui.nav_panel("Static 1", "Static 1", value="s1"), + ui.nav_panel("Static 2", "Static 2", value="s2"), + value="Menu", + ), + id="tabs", + ), +) + + +def server(input: Inputs, output: Outputs, session: Session): + + @reactive.effect + @reactive.event(input.update_foo) + def _(): + if input.update_foo() % 2 == 0: + ui.insert_nav_panel( + "tabs", + ui.nav_panel("Foo", "Foo is back now", value="Foo"), + target="Menu", + position="before", + select=True, + ) + else: + ui.remove_nav_panel("tabs", target="Foo") + + @reactive.effect + @reactive.event(input.add) + def _(): + id = "Dynamic-" + str(input.add()) + ui.insert_nav_panel( + "tabs", + ui.nav_panel(id, id, value=id), + target="s2", + position="before", + ) + + ui.notification_show(f"Added tab to menu: {id}") + + +app = App(app_ui, server) diff --git a/shiny/api-examples/insert_nav_panel/app-express.py b/shiny/api-examples/insert_nav_panel/app-express.py new file mode 100644 index 000000000..56e6df7f4 --- /dev/null +++ b/shiny/api-examples/insert_nav_panel/app-express.py @@ -0,0 +1,43 @@ +from shiny import reactive +from shiny.express import input, ui + +with ui.sidebar(): + ui.input_action_button("add", "Add 'Dynamic' tab") + ui.input_action_button("update_foo", "Add/Remove 'Foo' tab") + + +with ui.navset_tab(id="tabs"): + with ui.nav_panel("Hello", value="Hello"): + "This is the hello tab" + with ui.nav_panel("Foo", value="Foo"): + "This is the Foo tab" + with ui.nav_menu("Static", value="Menu"): + with ui.nav_panel("Static 1", value="s1"): + "Static 1" + with ui.nav_panel("Static 2", value="s2"): + "Static 2" + + +@reactive.effect +@reactive.event(input.update_foo) +def _(): + if input.update_foo() % 2 == 0: + ui.insert_nav_panel( + "tabs", + "Foo", + "Foo is back now", + value="Foo", + target="Menu", + position="before", + select=True, + ) + else: + ui.remove_nav_panel("tabs", target="Foo") + + +@reactive.effect +@reactive.event(input.add) +def _(): + id = "Dynamic-" + str(input.add()) + ui.insert_nav_panel("tabs", title=id, value=id, target="s2", position="before") + ui.notification_show(f"Added tab to menu: {id}") diff --git a/shiny/api-examples/update_nav_panel/app-core.py b/shiny/api-examples/update_nav_panel/app-core.py new file mode 100644 index 000000000..1d2d8987d --- /dev/null +++ b/shiny/api-examples/update_nav_panel/app-core.py @@ -0,0 +1,52 @@ +from shiny import App, Inputs, Outputs, Session, reactive, ui + +app_ui = ui.page_sidebar( + ui.sidebar( + "Home", + ui.input_action_button("hideTab", "Hide 'Foo' tab"), + ui.input_action_button("showTab", "Show 'Foo' tab"), + ui.input_action_button("hideMenu", "Hide 'More' nav_menu"), + ui.input_action_button("showMenu", "Show 'More' nav_menu"), + ), + ui.navset_tab( + ui.nav_panel("Foo", "This is the foo tab", value="Foo"), + ui.nav_panel("Bar", "This is the bar tab", value="Bar"), + ui.nav_menu( + "More", + ui.nav_panel("Table", "Table page"), + ui.nav_panel("About", "About page"), + "------", + "Even more!", + ui.nav_panel("Email", "Email page"), + value="More", + ), + id="tabs", + ), + title="Navbar page", + id="sidebar", +) + + +def server(input: Inputs, output: Outputs, session: Session): + @reactive.effect + @reactive.event(input.hideTab) + def _(): + ui.update_nav_panel("tabs", target="Foo", method="hide") + + @reactive.effect + @reactive.event(input.showTab) + def _(): + ui.update_nav_panel("tabs", target="Foo", method="show") + + @reactive.effect + @reactive.event(input.hideMenu) + def _(): + ui.update_nav_panel("tabs", target="More", method="hide") + + @reactive.effect + @reactive.event(input.showMenu) + def _(): + ui.update_nav_panel("tabs", target="More", method="show") + + +app = App(app_ui, server) diff --git a/shiny/api-examples/update_nav_panel/app-express.py b/shiny/api-examples/update_nav_panel/app-express.py new file mode 100644 index 000000000..841a2bd4e --- /dev/null +++ b/shiny/api-examples/update_nav_panel/app-express.py @@ -0,0 +1,45 @@ +from shiny import reactive +from shiny.express import input, ui + +with ui.layout_sidebar(): + with ui.sidebar(title="Navbar page", id="sidebar"): + "Home" + ui.input_action_button("hideTab", "Hide 'Foo' tab") + ui.input_action_button("showTab", "Show 'Foo' tab") + ui.input_action_button("hideMenu", "Hide 'More' nav_menu") + ui.input_action_button("showMenu", "Show 'More' nav_menu") + + with ui.navset_tab(id="tabs"): + with ui.nav_panel("Foo", value="Foo"): + "This is the foo tab" + with ui.nav_panel("Bar", value="Bar"): + "This is the bar tab" + with ui.nav_menu(title="More", value="More"): + with ui.nav_panel("Table"): + "Table page" + with ui.nav_panel("About"): + "About page" + "------" + "Even more!" + with ui.nav_panel("Email"): + "Email page" + + @reactive.effect + @reactive.event(input.hideTab) + def _(): + ui.update_nav_panel("tabs", target="Foo", method="hide") + + @reactive.effect + @reactive.event(input.showTab) + def _(): + ui.update_nav_panel("tabs", target="Foo", method="show") + + @reactive.effect + @reactive.event(input.hideMenu) + def _(): + ui.update_nav_panel("tabs", target="More", method="hide") + + @reactive.effect + @reactive.event(input.showMenu) + def _(): + ui.update_nav_panel("tabs", target="More", method="show") diff --git a/shiny/express/ui/__init__.py b/shiny/express/ui/__init__.py index 62a43de79..4c3d61967 100644 --- a/shiny/express/ui/__init__.py +++ b/shiny/express/ui/__init__.py @@ -109,6 +109,8 @@ notification_remove, nav_spacer, navbar_options, + remove_nav_panel, + update_nav_panel, Progress, Theme, value_box_theme, @@ -161,6 +163,10 @@ hold, ) +from ._insert import ( + insert_nav_panel, +) + __all__ = ( # Imports from htmltools "TagList", @@ -289,6 +295,9 @@ "navset_hidden", "navset_pill", "navset_pill_list", + "update_nav_panel", + "insert_nav_panel", + "remove_nav_panel", "navset_tab", "navset_underline", "navbar_options", diff --git a/shiny/express/ui/_insert.py b/shiny/express/ui/_insert.py new file mode 100644 index 000000000..91546a453 --- /dev/null +++ b/shiny/express/ui/_insert.py @@ -0,0 +1,86 @@ +""" +Shims for `ui.insert_*()`, `ui.update_*()`, etc. functions that lead to a more ergonomic +Express API. +These functions tend to have one issue in common: if they were re-exported verbatim from +Core to Express, they would want to take RecallContextManager(s) as input, which leads +to a somewhat awkward API. That's because, you'd have to know to use something like +@ui.hold() pass the UI as a value without displaying it. +""" + +from typing import Literal, Optional + +from htmltools import TagChild + +from ..._docstring import add_example +from ...session import Session + + +@add_example() +def insert_nav_panel( + id: str, + title: TagChild, + *args: TagChild, + value: Optional[str] = None, + icon: TagChild = None, + target: Optional[str] = None, + position: Literal["after", "before"] = "after", + select: bool = False, + session: Optional[Session] = None, +) -> None: + """ + Create a new nav panel in an existing navset. + + Parameters + ---------- + id + The id of the navset container to insert into. + title + A title for the inserted nav panel. Can be a character string or UI elements (i.e., tags). + *args + UI elements for the inserted nav panel. + value + The value of the panel. Use this value to determine whether the panel is active + (when an `id` is provided to the nav container) or to programmatically + select the item (e.g., :func:`~shiny.ui.update_navs`). You can also + provide the value to the `selected` argument of the navigation container + (e.g., :func:`~shiny.ui.navset_tab`). + icon + An icon to appear inline with the title. + target + The `value` of an existing :func:`shiny.ui.nav_panel`, next to which tab will + be added. Can also be `None`; see `position`. + position + The position of the new nav panel relative to the target. If + `target=None`, then `"before"` means the new panel should be inserted at + the head of the navlist, and `"after"` is the end. + select + Whether the nav panel should be selected upon insertion. + session + A :class:`~shiny.Session` instance. If not provided, it is inferred via + :func:`~shiny.session.get_current_session`. + + Note + ---- + Unlike :func:`~shiny.ui.insert_nav_panel`, this function does not support inserting + of a heading/divider into an existing :func:`~shiny.ui.nav_menu`. To do so, use + :func:`~shiny.ui.insert_nav_panel` instead of this Express variant (i.e., + `shiny.ui.insert_nav_panel("id", "Header")`). + """ + + from ...ui import insert_nav_panel, nav_panel + + panel = nav_panel( + title, + *args, + value=value, + icon=icon, + ) + + insert_nav_panel( + id=id, + nav_panel=panel, + target=target, + position=position, + select=select, + session=session, + ) diff --git a/shiny/ui/__init__.py b/shiny/ui/__init__.py index 9cbf0d74c..e1af2ff1b 100644 --- a/shiny/ui/__init__.py +++ b/shiny/ui/__init__.py @@ -128,6 +128,11 @@ navset_tab, navset_underline, ) +from ._navs_dynamic import ( + update_nav_panel, + insert_nav_panel, + remove_nav_panel, +) from ._notification import notification_remove, notification_show from ._output import ( output_code, @@ -297,6 +302,9 @@ "navset_pill_list", "navset_hidden", "navset_bar", + "insert_nav_panel", + "remove_nav_panel", + "update_nav_panel", "navbar_options", # _notification "notification_show", diff --git a/shiny/ui/_navs_dynamic.py b/shiny/ui/_navs_dynamic.py new file mode 100644 index 000000000..ddd98cc15 --- /dev/null +++ b/shiny/ui/_navs_dynamic.py @@ -0,0 +1,173 @@ +__all__ = ("insert_nav_panel", "remove_nav_panel", "update_nav_panel") + +import sys +from typing import Optional, Union + +if sys.version_info >= (3, 8): + from typing import Literal +else: + from typing_extensions import Literal + +from .._docstring import add_example, no_example +from .._namespaces import resolve_id +from ..session import Session, require_active_session +from ..types import NavSetArg +from ._navs import menu_string_as_nav + + +@add_example() +def insert_nav_panel( + id: str, + nav_panel: Union[NavSetArg, str], + target: Optional[str] = None, + position: Literal["after", "before"] = "after", + select: bool = False, + session: Optional[Session] = None, +) -> None: + """ + Insert a new nav item into a navigation container. + + Parameters + ---------- + id + The `id` of the relevant navigation container (i.e., `navset_*()` object). + nav_panel + The navigation item to insert (typically a :func:`~shiny.ui.nav_panel` or + :func:`~shiny.ui.nav_menu`). A :func:`~shiny.ui.nav_menu` isn't allowed when the + `target` references an :func:`~shiny.ui.nav_menu` (or an item within it). A + string is only allowed when the `target` references a + :func:`~shiny.ui.nav_menu`. + target + The `value` of an existing :func:`shiny.ui.nav_panel`, next to which tab will + be added. Can also be `None`; see `position`. + position + The position of the new nav item relative to the target nav item. If + `target=None`, then `"before"` means the new nav item should be inserted at + the head of the navlist, and `"after"` is the end. + select + Whether the nav item should be selected upon insertion. + session + A :class:`~shiny.Session` instance. If not provided, it is inferred via + :func:`~shiny.session.get_current_session`. + + See Also + -------- + ~shiny.ui.remove_nav_panel + ~shiny.ui.update_nav_panel + ~shiny.ui.nav_panel + """ + + session = require_active_session(session) + + # N.B. this is only sensible if the target is a menu, but we don't know that, + # which could cause confusion of we decide to support top-level strings at some + # in the future. + if isinstance(nav_panel, str): + nav = menu_string_as_nav(nav_panel) + else: + nav = nav_panel + + # N.B. shiny.js' is smart enough to know how to add active classes and href/id attrs + li_tag, div_tag = nav.resolve( + selected=None, context=dict(tabsetid="tsid", index="id") + ) + + msg = { + "inputId": resolve_id(id), + "liTag": session._process_ui(li_tag), + "divTag": session._process_ui(div_tag), + "menuName": None, + "target": target, + "position": position, + "select": select, + } + + session._send_message_sync({"shiny-insert-tab": msg}) + + +@no_example() +def remove_nav_panel(id: str, target: str, session: Optional[Session] = None) -> None: + """ + Remove a nav item from a navigation container. + + Parameters + ---------- + id + The `id` of the relevant navigation container (i.e., `navset_*()` object). + target + The `value` of an existing :func:`shiny.ui.nav_panel` item to remove. + session + A :class:`~shiny.Session` instance. If not provided, it is inferred via + :func:`~shiny.session.get_current_session`. + + Note + ---- + See :func:`~shiny.ui.insert_nav_panel` for an example. + + See Also + -------- + ~shiny.ui.insert_nav_panel + ~shiny.ui.update_nav_panel + ~shiny.ui.nav_panel + """ + + session = require_active_session(session) + msg = { + "inputId": resolve_id(id), + "target": target, + } + + session._send_message_sync({"shiny-remove-tab": msg}) + + +@add_example() +def update_nav_panel( + id: str, + target: str, + method: Literal["show", "hide"], + session: Optional[Session] = None, +) -> None: + """ + Show/hide a navigation item + + Parameters + ---------- + id + The `id` of the relevant navigation container (i.e., `navset_*()` object). + target + The `value` of an existing :func:`shiny.ui.nav_panel` item to show. + method + The action to perform on the nav_panel (`"show"` or `"hide"`). + session + A :class:`~shiny.Session` instance. If not provided, it is inferred via + :func:`~shiny.session.get_current_session`. + + Note + ---- + On reveal, the `nav_panel` will not be the active tab. To change the active tab, use :func:`~shiny.ui.update_navs()` + For example: + ```python + @reactive.effect + @reactive.event(input.show_tab) + def _(): + ui.update_nav_panel("tabset_id", target="Foo", method="show") + ui.update_navs("tabset_id", selected="Foo") + ``` + + See Also + -------- + ~shiny.ui.insert_nav_panel + ~shiny.ui.remove_nav_panel + ~shiny.ui.nav_panel + ~shiny.ui.update_navs + """ + + session = require_active_session(session) + + msg = { + "inputId": resolve_id(id), + "target": target, + "type": method, + } + + session._send_message_sync({"shiny-change-tab-visibility": msg}) diff --git a/tests/playwright/shiny/components/dynamic_navs/app.py b/tests/playwright/shiny/components/dynamic_navs/app.py new file mode 100644 index 000000000..6fa4f2e34 --- /dev/null +++ b/tests/playwright/shiny/components/dynamic_navs/app.py @@ -0,0 +1,87 @@ +from shiny import App, Inputs, Outputs, Session, reactive, ui + +app_ui = ui.page_sidebar( + ui.sidebar( + ui.input_action_button("add", "Add 'Dynamic' tab"), + ui.input_action_button("remove_foo", "Remove 'Foo' tabs"), + ui.input_action_button("add_foo", "Add New 'Foo' tab"), + ui.input_action_button("hide_tab", "Hide 'Foo' tab"), + ui.input_action_button("show_tab", "Show 'Foo' tab"), + ui.input_action_button("hide_menu", "Hide 'Static' nav_menu"), + ui.input_action_button("show_menu", "Show 'Static' nav_menu"), + ), + ui.navset_tab( + ui.nav_panel("Hello", "This is the hello tab", value="Hello"), + ui.nav_panel("Foo", "This is the Foo tab", value="Foo"), + ui.nav_menu( + "Menu", + ui.nav_panel("Static1", "Static1", value="s1"), + ui.nav_panel("Static2", "Static2", value="s2"), + value="Menu", + ), + id="tabs", + ), +) + + +def server(input: Inputs, output: Outputs, session: Session): + + @reactive.effect + def _(): + ui.insert_nav_panel( + "tabs", + "Stringier Panel", + target="s2", + position="before", + ) + + @reactive.effect + @reactive.event(input.add) + def _(): + id = "Dynamic-" + str(input.add()) + ui.insert_nav_panel( + "tabs", + ui.nav_panel(id, id), + target="s2", + position="before", + ) + + @reactive.effect + @reactive.event(input.remove_foo) + def _(): + ui.remove_nav_panel("tabs", target="Foo") + + @reactive.effect + @reactive.event(input.add_foo) + def _(): + n = str(input.add_foo()) + ui.insert_nav_panel( + "tabs", + ui.nav_panel("Foo-" + n, "Foo-" + n, value="Foo-" + n), + target="Menu", + position="before", + select=True, + ) + + @reactive.effect + @reactive.event(input.hide_tab) + def _(): + ui.update_nav_panel("tabs", target="Foo", method="hide") + + @reactive.effect + @reactive.event(input.show_tab) + def _(): + ui.update_nav_panel("tabs", target="Foo", method="show") + + @reactive.effect + @reactive.event(input.hide_menu) + def _(): + ui.update_nav_panel("tabs", target="Menu", method="hide") + + @reactive.effect + @reactive.event(input.show_menu) + def _(): + ui.update_nav_panel("tabs", target="Menu", method="show") + + +app = App(app_ui, server) diff --git a/tests/playwright/shiny/components/dynamic_navs/test_navs_dynamic.py b/tests/playwright/shiny/components/dynamic_navs/test_navs_dynamic.py new file mode 100644 index 000000000..31a223042 --- /dev/null +++ b/tests/playwright/shiny/components/dynamic_navs/test_navs_dynamic.py @@ -0,0 +1,94 @@ +# import pytest +from playwright.sync_api import Page, expect + +from shiny.playwright import controller +from shiny.run import ShinyAppProc + + +def test_dynamic_navs(page: Page, local_app: ShinyAppProc) -> None: + page.goto(local_app.url) + + # String insertion as panels worked correctly + page.get_by_role("button", name="Menu", exact=True).click() + expect(page.get_by_text("Stringier Panel")).to_be_visible() + page.get_by_role("button", name="Menu", exact=True).click() + + # Page begins with 2 tabs: "Hello" and "Foo" and a nav menu with 2 static items. + controller.NavsetTab(page, "tabs").expect_nav_titles( + ["Hello", "Foo", "Static1", "Static2"] + ) + + # Click add-foo to add a new Foo tab + addfoo = controller.InputActionButton(page, "add_foo") + addfoo.click() + controller.NavsetTab(page, "tabs").expect_nav_titles( + ["Hello", "Foo", "Foo-1", "Static1", "Static2"] + ) + # Check that Foo-1 tab is added + expect(controller.NavPanel(page, "tabs", "Foo-1").loc).to_be_visible() + + # Click hide-tab to hide the Foo tabs + hidetab = controller.InputActionButton(page, "hide_tab") + hidetab.click() + + # Expect the Foo tab to be hidden + navpanel = controller.NavPanel(page, "tabs", "Foo").loc + expect(navpanel).to_be_hidden() + navpanel = controller.NavPanel(page, "tabs", "Foo-1").loc + expect(navpanel).to_be_visible() + + # Click show-tab to show the Foo tabs again + showtab = controller.InputActionButton(page, "show_tab") + showtab.click() + + # Expect the Foo tabs to be visible again + navpanel2 = controller.NavPanel(page, "tabs", "Foo").loc + expect(navpanel2).to_be_visible() + navpanel3 = controller.NavPanel(page, "tabs", "Foo-1").loc + expect(navpanel3).to_be_visible() + + # Click remove-foo to remove the Foo tab + removefoo = controller.InputActionButton(page, "remove_foo") + removefoo.click() + controller.NavsetTab(page, "tabs").expect_nav_titles( + ["Hello", "Foo-1", "Static1", "Static2"] + ) + + # Click add to add a dynamic tab + add = controller.InputActionButton(page, "add") + add.click() + controller.NavsetTab(page, "tabs").expect_nav_titles( + ["Hello", "Foo-1", "Static1", "Dynamic-1", "Static2"] + ) + + # Click add again to add another dynamic tab + add.click() + controller.NavsetTab(page, "tabs").expect_nav_titles( + ["Hello", "Foo-1", "Static1", "Dynamic-1", "Dynamic-2", "Static2"] + ) + + page.get_by_role("button", name="Menu", exact=True).click() + + # Expect static tabs to be visible + navpanel3 = controller.NavPanel(page, "tabs", "s1").loc + expect(navpanel3).to_be_visible() + + # Click hide-menu to hide the static menu + hidemenu = controller.InputActionButton(page, "hide_menu") + hidemenu.click() + + # Expect the Menu to be hidden + navpanel3 = controller.NavPanel(page, "tabs", "s1").loc + expect(navpanel3).to_be_hidden() + + # Click show-menu to show the static menu again + showmenu = controller.InputActionButton(page, "show_menu") + showmenu.click() + + # Expect the Menu to be visible again + expect(page.get_by_role("button", name="Menu", exact=True)).to_be_visible() + + # Click the Menu button to show the static menu and expect the panels to be visible again + page.get_by_role("button", name="Menu", exact=True).click() + navpanel3 = controller.NavPanel(page, "tabs", "s1").loc + expect(navpanel3).to_be_visible() diff --git a/tests/playwright/shiny/components/express_navs/app.py b/tests/playwright/shiny/components/express_navs/app.py new file mode 100644 index 000000000..3927da4ab --- /dev/null +++ b/tests/playwright/shiny/components/express_navs/app.py @@ -0,0 +1,39 @@ +from shiny import Inputs, Outputs, Session, reactive +from shiny.express import module, ui + + +@module +def my_nav(input: Inputs, output: Outputs, session: Session): + with ui.navset_card_tab(id="navset"): + with ui.nav_panel("Panel 1"): + "This is the first panel" + ui.input_action_button("hide_tab", "Hide panel 2") + ui.input_action_button("show_tab", "Show panel 2") + ui.input_action_button("delete_tabs", "Delete panel 2") + + @reactive.effect + def _(): + ui.insert_nav_panel( + "navset", + "Panel 2", + "This is the second panel", + ) + + @reactive.effect + @reactive.event(input.show_tab) + def _(): + ui.update_nav_panel("navset", target="Panel 2", method="show") + + @reactive.effect + @reactive.event(input.hide_tab) + def _(): + ui.update_nav_panel("navset", target="Panel 2", method="hide") + + @reactive.effect + @reactive.event(input.delete_tabs) + def _(): + ui.remove_nav_panel("navset", "Panel 2") + + +my_nav("foo") +my_nav("bar") diff --git a/tests/playwright/shiny/components/express_navs/test_express_navs.py b/tests/playwright/shiny/components/express_navs/test_express_navs.py new file mode 100644 index 000000000..49d28a0ed --- /dev/null +++ b/tests/playwright/shiny/components/express_navs/test_express_navs.py @@ -0,0 +1,42 @@ +from playwright.sync_api import Page, expect + +from shiny.playwright import controller +from shiny.run import ShinyAppProc + + +def test_dynamic_navs(page: Page, local_app: ShinyAppProc) -> None: + page.goto(local_app.url) + + # Page begins with 2 tabs: "Panel 1" and "Panel 2" + controller.NavsetTab(page, "foo-navset").expect_nav_titles(["Panel 1", "Panel 2"]) + controller.NavsetTab(page, "bar-navset").expect_nav_titles(["Panel 1", "Panel 2"]) + + # Click hide-tab to hide Panel 2 in the foo navset + hidetab = controller.InputActionButton(page, "foo-hide_tab") + hidetab.click() + + # Expect the Foo's Panel 2 to be hidden + navpanel = controller.NavPanel(page, "foo-navset", "Panel 2").loc + expect(navpanel).to_be_hidden() + + # Expect the bar Panel 2 tab to not be affected + navpanel2 = controller.NavPanel(page, "bar-navset", "Panel 2").loc + expect(navpanel2).to_be_visible() + + # Click show-tab to show the foo Panel 2 tab again + showtab = controller.InputActionButton(page, "foo-show_tab") + showtab.click() + + # Expect the Foo Panel 2 tab to be visible again as well as the bar Panel 2 + navpanel2 = controller.NavPanel(page, "foo-navset", "Panel 2").loc + expect(navpanel2).to_be_visible() + navpanel3 = controller.NavPanel(page, "bar-navset", "Panel 2").loc + expect(navpanel3).to_be_visible() + + # Click the remove button to remove the panel 2 in bar + removeTab = controller.InputActionButton(page, "bar-delete_tabs") + removeTab.click() + + # Check that bar's Panel 2 is gone, but foo's Panel 2 is unaffected + controller.NavsetTab(page, "bar-navset").expect_nav_titles(["Panel 1"]) + controller.NavsetTab(page, "foo-navset").expect_nav_titles(["Panel 1", "Panel 2"])