diff --git a/CHANGELOG.md b/CHANGELOG.md index 96323b3ee..d7444b9e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,17 +8,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [UNRELEASED] +### New features + +* Added `shiny.render.renderer_components` decorator to help create new output renderers (#621). +* Added `shiny.experimental.ui.popover()`, `update_popover()`, and `toggle_popover()` for easy creation (and server-side updating) of [Bootstrap popovers](https://getbootstrap.com/docs/5.2/components/popovers/). Popovers are similar to tooltips, but are more persistent, and should primarily be used with button-like UI elements (e.g. `input_action_button()` or icons) (#680). +* Added `shiny.experimental.ui.toggle_switch()` (#680). +* Added CSS classes to UI input methods (#680) . +* `Session` objects can now accept an asynchronous (or synchronous) function for `.on_flush(fn=)`, `.on_flushed(fn=)`, and `.on_ended(fn=)` (#686). + ### API changes * Renamed `shiny.ui.navset_pill_card` to `shiny.ui.navset_card_pill`. `shiny.ui.navset_pill_card` will throw a deprecated warning (#492). * Renamed `shiny.ui.navset_tab_card` to `shiny.ui.navset_card_tab`. `shiny.ui.navset_tab_card` will throw a deprecated warning (#492). + +#### Experimental API changes + * Renamed `shiny.experimental.ui.navset_pill_card` to `shiny.experimental.ui.navset_card_pill` (#492). * Renamed `shiny.experimental.ui.navset_tab_card` to `shiny.experimental.ui.navset_card_tab` (#492). +* Renamed `shiny.experimental.ui.sidebar_toggle()` to `shiny.experimental.ui.toggle_sidebar()` (#680). +* Renamed `shiny.experimental.ui.tooltip_toggle()` to `shiny.experimental.ui.toggle_tooltip()` (#680). +* Renamed `shiny.experimental.ui.tooltip_update()` to `shiny.experimental.ui.update_tooltip()` (#680). -### New features - -* Added `shiny.render.renderer_components` decorator to help create new output renderers. (#621) -* `Session` objects can now accept an asynchronous (or synchronous) function for `.on_flush(fn=)`, `.on_flushed(fn=)`, and `.on_ended(fn=)` (#686). ### Bug fixes diff --git a/docs/_quartodoc.yml b/docs/_quartodoc.yml index 6d032d539..48be46a24 100644 --- a/docs/_quartodoc.yml +++ b/docs/_quartodoc.yml @@ -249,7 +249,8 @@ quartodoc: - experimental.ui.navset_bar - experimental.ui.navset_card_tab - experimental.ui.navset_card_pill - - experimental.ui.sidebar_toggle + - experimental.ui.toggle_sidebar + - experimental.ui.toggle_switch - experimental.ui.panel_main - experimental.ui.panel_sidebar - experimental.ui.Sidebar @@ -306,8 +307,18 @@ quartodoc: flatten: true contents: - experimental.ui.tooltip - - experimental.ui.tooltip_toggle + - experimental.ui.toggle_tooltip - experimental.ui.update_tooltip + - kind: page + path: ExPopover + summary: + name: "Popovers" + desc: "Display additional information when clicking on a UI element (typically a button)." + flatten: true + contents: + - experimental.ui.popover + - experimental.ui.toggle_popover + - experimental.ui.update_popover - kind: page path: ExFillingLayout summary: diff --git a/scripts/htmlDependencies.R b/scripts/htmlDependencies.R index 512a659ef..fd1ebdaa1 100755 --- a/scripts/htmlDependencies.R +++ b/scripts/htmlDependencies.R @@ -253,7 +253,7 @@ fs::dir_copy(x_www_htmltools_fill, main_x_htmltools_fill) fs::file_delete( fs::dir_ls( fs::path(main_x_bslib_components, "components"), - regexp="(_version|sidebar|nav_spacer)", + regexp="(_version|sidebar|nav_spacer|bslibShiny)", invert = TRUE ) ) diff --git a/shiny/_versions.py b/shiny/_versions.py index 9ff6b1d3b..ade1d50d5 100644 --- a/shiny/_versions.py +++ b/shiny/_versions.py @@ -1,4 +1,4 @@ -shiny_html_deps = "1.7.4.9002" +shiny_html_deps = "1.7.4.9003" bslib = "0.5.0.9000" htmltools = "0.5.5.9000" bootstrap = "5.2.2" diff --git a/shiny/experimental/api-examples/popover/app.py b/shiny/experimental/api-examples/popover/app.py new file mode 100644 index 000000000..9d233a7f5 --- /dev/null +++ b/shiny/experimental/api-examples/popover/app.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import shiny.experimental as x +from shiny import App, Inputs, Outputs, Session, render, ui + +# https://icons.getbootstrap.com/icons/gear-fill/ +gear_fill = ui.HTML( + '' +) + +app_ui = ui.page_fluid( + x.ui.popover( + ui.input_action_button("btn", "A button", class_="mt-3"), + "A popover with more context and information than should be used in a tooltip.", + "You can even have multiple DOM elements in a popover!", + id="btn_popover", + ), + ui.hr(), + x.ui.card( + x.ui.card_header( + "Plot title (Click the gear to change variables)", + x.ui.popover( + ui.span( + gear_fill, + style="position:absolute; top: 5px; right: 7px;", + ), + "Put dropdowns here to alter your plot!", + ui.input_selectize("x", "X", ["x1", "x2", "x3"]), + ui.input_selectize("y", "Y", ["y1", "y2", "y3"]), + placement="right", + id="card_popover", + ), + ), + ui.output_text_verbatim("plot_txt", placeholder=True), + ), +) + + +def server(input: Inputs, output: Outputs, session: Session): + @output + @render.text + def plot_txt(): + return f"" + + +app = App(app_ui, server=server) diff --git a/shiny/experimental/api-examples/toggle_popover/app.py b/shiny/experimental/api-examples/toggle_popover/app.py new file mode 100644 index 000000000..ca905450f --- /dev/null +++ b/shiny/experimental/api-examples/toggle_popover/app.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import shiny.experimental as x +from shiny import App, Inputs, Outputs, Session, reactive, req, ui + +app_ui = ui.page_fluid( + ui.input_action_button("btn_show", "Show popover", class_="mt-3 me-3"), + ui.input_action_button("btn_close", "Close popover", class_="mt-3 me-3"), + ui.br(), + ui.input_action_button("btn_toggle", "Toggle popover", class_="mt-3 me-3"), + ui.br(), + ui.br(), + x.ui.popover( + ui.input_action_button("btn_w_popover", "A button w/ a popover", class_="mt-3"), + "A message", + id="popover_id", + ), +) + + +def server(input: Inputs, output: Outputs, session: Session): + @reactive.Effect + def _(): + req(input.btn_show()) + + x.ui.toggle_popover("popover_id", show=True) + + @reactive.Effect + def _(): + req(input.btn_close()) + + x.ui.toggle_popover("popover_id", show=False) + + @reactive.Effect + def _(): + req(input.btn_toggle()) + + x.ui.toggle_popover("popover_id") + + @reactive.Effect + def _(): + req(input.btn_w_popover()) + ui.notification_show("Button clicked!", duration=3, type="message") + + +app = App(app_ui, server=server) diff --git a/shiny/experimental/api-examples/sidebar_toggle/app.py b/shiny/experimental/api-examples/toggle_sidebar/app.py similarity index 84% rename from shiny/experimental/api-examples/sidebar_toggle/app.py rename to shiny/experimental/api-examples/toggle_sidebar/app.py index 25fa1e01b..c10c67303 100644 --- a/shiny/experimental/api-examples/sidebar_toggle/app.py +++ b/shiny/experimental/api-examples/toggle_sidebar/app.py @@ -6,7 +6,7 @@ app_ui = x.ui.page_sidebar( x.ui.sidebar("Sidebar content", id="sidebar"), ui.input_action_button( - "sidebar_toggle", + "toggle_sidebar", label="Toggle sidebar", width="fit-content", ), @@ -16,9 +16,9 @@ def server(input: Inputs, output: Outputs, session: Session): @reactive.Effect - @reactive.event(input.sidebar_toggle) + @reactive.event(input.toggle_sidebar) def _(): - x.ui.sidebar_toggle("sidebar") + x.ui.toggle_sidebar("sidebar") @output @render.text diff --git a/shiny/experimental/api-examples/toggle_switch/app.py b/shiny/experimental/api-examples/toggle_switch/app.py new file mode 100644 index 000000000..8a6a022aa --- /dev/null +++ b/shiny/experimental/api-examples/toggle_switch/app.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +import shiny.experimental as x +from shiny import App, Inputs, Outputs, Session, reactive, render, ui + +app_ui = ui.page_fluid( + ui.input_switch("switch_value", label="Switch"), + ui.input_action_button( + "toggle_btn", + label="Toggle the switch", + width="fit-content", + ), + ui.output_text_verbatim("state"), +) + + +def server(input: Inputs, output: Outputs, session: Session): + @reactive.Effect + @reactive.event(input.toggle_btn) + def _(): + x.ui.toggle_switch("switch_value") + + @output + @render.text + def state(): + return f"input.switch(): {input.switch_value()}" + + +app = App(app_ui, server=server) diff --git a/shiny/experimental/api-examples/tooltip_toggle/app.py b/shiny/experimental/api-examples/toggle_tooltip/app.py similarity index 87% rename from shiny/experimental/api-examples/tooltip_toggle/app.py rename to shiny/experimental/api-examples/toggle_tooltip/app.py index c868ca94b..520cc00ae 100644 --- a/shiny/experimental/api-examples/tooltip_toggle/app.py +++ b/shiny/experimental/api-examples/toggle_tooltip/app.py @@ -23,19 +23,19 @@ def server(input: Inputs, output: Outputs, session: Session): def _(): req(input.btn_show()) - x.ui.tooltip_toggle("tooltip_id", show=True) + x.ui.toggle_tooltip("tooltip_id", show=True) @reactive.Effect def _(): req(input.btn_close()) - x.ui.tooltip_toggle("tooltip_id", show=False) + x.ui.toggle_tooltip("tooltip_id", show=False) @reactive.Effect def _(): req(input.btn_toggle()) - x.ui.tooltip_toggle("tooltip_id") + x.ui.toggle_tooltip("tooltip_id") @reactive.Effect def _(): diff --git a/shiny/experimental/api-examples/update_popover/app.py b/shiny/experimental/api-examples/update_popover/app.py new file mode 100644 index 000000000..45211cf6a --- /dev/null +++ b/shiny/experimental/api-examples/update_popover/app.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import shiny.experimental as x +from shiny import App, Inputs, Outputs, Session, reactive, req, ui + +app_ui = ui.page_fluid( + ui.input_action_button("btn_update", "Update popover phrase", class_="mt-3 me-3"), + ui.br(), + ui.br(), + x.ui.popover( + ui.input_action_button("btn_w_popover", "A button w/ a popover", class_="mt-3"), + "A message", + id="popover_id", + title="To start", + ), +) + + +def server(input: Inputs, output: Outputs, session: Session): + @reactive.Effect + def _(): + # Immediately display popover + x.ui.toggle_popover("popover_id", show=True) + + @reactive.Effect + @reactive.event(input.btn_update) + def _(): + content = ( + "A " + " ".join(["NEW" for _ in range(input.btn_update())]) + " message" + ) + + x.ui.update_popover("popover_id", content) + # x.ui.toggle_popover("popover_id", show=True) + + @reactive.Effect + def _(): + req(input.btn_w_popover()) + ui.notification_show("Button clicked!", duration=3, type="message") + + +app = App(app_ui, server=server) diff --git a/shiny/experimental/api-examples/update_tooltip/app.py b/shiny/experimental/api-examples/update_tooltip/app.py index 169ce5c55..9d0ce7399 100644 --- a/shiny/experimental/api-examples/update_tooltip/app.py +++ b/shiny/experimental/api-examples/update_tooltip/app.py @@ -1,7 +1,7 @@ from __future__ import annotations import shiny.experimental as x -from shiny import App, Inputs, Outputs, Session, reactive, req, ui +from shiny import App, Inputs, Outputs, Session, reactive, ui app_ui = ui.page_fluid( ui.input_action_button("btn_update", "Update tooltip phrase", class_="mt-3 me-3"), @@ -19,22 +19,21 @@ def server(input: Inputs, output: Outputs, session: Session): @reactive.Effect def _(): # Immediately display tooltip - x.ui.tooltip_toggle("tooltip_id", show=True) + x.ui.toggle_tooltip("tooltip_id", show=True) @reactive.Effect + @reactive.event(input.btn_update) def _(): - req(input.btn_update()) - content = ( "A " + " ".join(["NEW" for _ in range(input.btn_update())]) + " message" ) x.ui.update_tooltip("tooltip_id", content) - x.ui.tooltip_toggle("tooltip_id", show=True) + x.ui.toggle_tooltip("tooltip_id", show=True) @reactive.Effect + @reactive.event(input.btn_w_tooltip) def _(): - req(input.btn_w_tooltip()) ui.notification_show("Button clicked!", duration=3, type="message") diff --git a/shiny/experimental/e2e/sidebar/app.py b/shiny/experimental/e2e/sidebar/app.py index 46a581a1e..90a5f0e13 100644 --- a/shiny/experimental/e2e/sidebar/app.py +++ b/shiny/experimental/e2e/sidebar/app.py @@ -67,24 +67,24 @@ def ui_content(): @reactive.Effect @reactive.event(input.open_all) def _(): - x.ui.sidebar_toggle("sidebar_inner", open=True) - x.ui.sidebar_toggle("sidebar_outer", open=True) + x.ui.toggle_sidebar("sidebar_inner", open=True) + x.ui.toggle_sidebar("sidebar_outer", open=True) @reactive.Effect @reactive.event(input.close_all) def _(): - x.ui.sidebar_toggle("sidebar_inner", open=False) - x.ui.sidebar_toggle("sidebar_outer", open=False) + x.ui.toggle_sidebar("sidebar_inner", open=False) + x.ui.toggle_sidebar("sidebar_outer", open=False) @reactive.Effect @reactive.event(input.toggle_inner) def _(): - x.ui.sidebar_toggle("sidebar_inner") + x.ui.toggle_sidebar("sidebar_inner") @reactive.Effect @reactive.event(input.toggle_outer) def _(): - x.ui.sidebar_toggle("sidebar_outer") + x.ui.toggle_sidebar("sidebar_outer") app = App(app_ui, server) diff --git a/shiny/experimental/ui/__init__.py b/shiny/experimental/ui/__init__.py index 6f46660dc..8515d05e0 100644 --- a/shiny/experimental/ui/__init__.py +++ b/shiny/experimental/ui/__init__.py @@ -32,6 +32,7 @@ is_fillable_container, remove_all_fill, ) +from ._input_switch import toggle_switch from ._input_text import input_text_area from ._layout import layout_column_wrap from ._navs import navset_bar, navset_card_pill, navset_card_tab @@ -45,9 +46,10 @@ panel_main, panel_sidebar, sidebar, - sidebar_toggle, + toggle_sidebar, ) -from ._tooltip import tooltip, tooltip_toggle, update_tooltip +from ._tooltip import tooltip, toggle_tooltip, update_tooltip +from ._popover import popover, toggle_popover, update_popover from ._valuebox import showcase_left_center, showcase_top_right, value_box __all__ = ( @@ -63,7 +65,7 @@ "panel_main", "panel_sidebar", "sidebar", - "sidebar_toggle", + "toggle_sidebar", # Page "page_sidebar", "page_fillable", @@ -85,9 +87,13 @@ "card_footer", # Layout "layout_column_wrap", + # Popover + "popover", + "toggle_popover", + "update_popover", # Tooltip "tooltip", - "tooltip_toggle", + "toggle_tooltip", "update_tooltip", # ValueBox "value_box", @@ -107,6 +113,8 @@ "output_image", "output_plot", "output_ui", + # input_switch + "toggle_switch", # input_text_area "input_text_area", # Accordion diff --git a/shiny/experimental/ui/_card.py b/shiny/experimental/ui/_card.py index 47a1acbb6..f25a3f8d6 100644 --- a/shiny/experimental/ui/_card.py +++ b/shiny/experimental/ui/_card.py @@ -106,7 +106,7 @@ def card( tag = div( { - "class": "card bslib-card bslib-mb-spacer", + "class": "card bslib-card bslib-mb-spacing", "style": css( height=as_css_unit(height), max_height=as_css_unit(max_height), diff --git a/shiny/experimental/ui/_input_switch.py b/shiny/experimental/ui/_input_switch.py new file mode 100644 index 000000000..fdfb67e1b --- /dev/null +++ b/shiny/experimental/ui/_input_switch.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from typing import Optional + +from ... import Session +from ..._utils import drop_none +from ...session import require_active_session + + +def toggle_switch( + id: str, value: Optional[bool] = None, session: Optional[Session] = None +): + """ + Toggle a switch input. + + Parameters + ---------- + id + The id of the switch input. + value + The new value of the switch input. If `NULL`, the value will be toggled. + session + The session object passed to `server()`. + """ + + if value is not None and not isinstance(value, bool): + raise TypeError("`value` must be `None` or a single boolean value.") + + msg = drop_none({"id": id, "value": value}) + session = require_active_session(session) + + async def callback(): + await session.send_custom_message("bslib.toggle-input-binary", msg) + + session.on_flush(callback, once=True) diff --git a/shiny/experimental/ui/_input_text.py b/shiny/experimental/ui/_input_text.py index fe48cabfa..f609cc892 100644 --- a/shiny/experimental/ui/_input_text.py +++ b/shiny/experimental/ui/_input_text.py @@ -114,6 +114,6 @@ def input_text_area( shiny_input_label(id, label), area, autoresize_dependency() if autoresize else None, - class_="form-group shiny-input-container", + class_="shiny-input-textarea form-group shiny-input-container", style=css(width=width), ) diff --git a/shiny/experimental/ui/_layout.py b/shiny/experimental/ui/_layout.py index 9f878ba01..bce1687fb 100644 --- a/shiny/experimental/ui/_layout.py +++ b/shiny/experimental/ui/_layout.py @@ -115,7 +115,7 @@ def layout_column_wrap( tag = div( { - "class": "bslib-grid", + "class": "bslib-grid bslib-mb-spacing", "style": css(**tag_style_css), }, attrs, diff --git a/shiny/experimental/ui/_popover.py b/shiny/experimental/ui/_popover.py new file mode 100644 index 000000000..9b121b7a3 --- /dev/null +++ b/shiny/experimental/ui/_popover.py @@ -0,0 +1,203 @@ +from __future__ import annotations + +import json +from typing import Any, Literal, Optional + +from htmltools import Tag, TagAttrs, TagAttrValue, TagChild, TagList, div, tags + +from ... import Session +from ..._utils import drop_none +from ...session import require_active_session +from ._tooltip import _normalize_show_value, _session_on_flush_send_msg +from ._utils import consolidate_attrs +from ._web_component import web_component + +__all__ = ( + "popover", + "toggle_popover", + "update_popover", +) + + +def popover( + trigger: TagChild, + *args: TagChild | TagAttrs, + title: Optional[TagChild] = None, + id: Optional[str] = None, + placement: Literal["auto", "top", "right", "bottom", "left"] = "auto", + options: Optional[dict[str, Any]] = None, + **kwargs: TagAttrValue, +) -> Tag: + """ + Add a popover to a UI element. + + Display additional information when clicking on a UI element (typically a + button). + + Parameters + ---------- + trigger + The UI element to serve as the popover trigger (typically a + :func:`~shiny.ui.input_action_button` or similar). If `trigger` renders as + multiple HTML elements (e.g., it's a :func:`~shiny.ui.tags.TagList`), the last + HTML element is used for the trigger. If the `trigger` should contain all of + those elements, wrap the object in a :func:`~shiny.ui.tags.div` or + :func:`~shiny.ui.tags.span`. + *args + UI elements for the popover's body. Character strings are + automatically escaped unless marked as :func:`~shiny.html`. + title + A title (header) for the popover. + id + A character string. Required to re-actively respond to the visibility of the + popover (via the `input.()` value) and/or update the visibility/contents of + the popover. + placement + The placement of the popover relative to its trigger. + options + A list of additional + `options `_. + + + Closing popovers + ---------------- + + In addition to clicking the `close_button`, popovers can be closed by pressing the + Esc/Space key when the popover (and/or its trigger) is focused. + + See Also + -------- + * + * :func:`~shiny.experimental.ui.toggle_popover` + * :func:`~shiny.experimental.ui.update_popover` + * :func:`~shiny.experimental.ui.tooltip` + """ + + # Theming/Styling + # --------------- + # + # Like other bslib components, popovers can be themed by supplying [relevant theming + # variables](https://rstudio.github.io/bslib/articles/bs5-variables.html#popover-bg) + # to [bs_theme()], which effects styling of every popover on the page. To style a + # _specific_ popover differently from other popovers, utilize the `customClass` + # option: + # + # ``` + # popover( + # "Trigger", "Popover message", + # options = list(customClass = "my-pop") + # ) + # ``` + # + # And then add relevant rules to [bs_theme()] via [bs_add_rules()]: + # + # ``` + # bs_theme() |> bs_add_rules(".my-pop { max-width: none; }") + # ``` + + attrs, children = consolidate_attrs(*args, **kwargs) + if len(children) == 0: + raise RuntimeError("At least one value must be provided to `popover(*args)`.") + + if options: + for name in ("content", "title", "placement"): + if name in options: + raise RuntimeError( + f"The key `{name}` in `popover(options=)` cannot be specified directly." + ) + + res = web_component( + "bslib-popover", + # Use display:none instead of