diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ad5f5d1d..fa69c8e9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Added `include_js()` and `include_css()`, for easily including JS and CSS files in an application. (#127) +* Added sidebar, card, value box, and accordion methods into `shiny.experimental.ui`. (#481) + +* Added `fill` and `fillable` methods into `shiny.experimental.ui`. If `fill` is `True`, then the UI component is allowed to expand into the parent container. If `fillable` is `True`, then the UI component will allow its content to expand. Both `fill` on the child component and `fillable` on the parent component must be `True` for the child component to expand. (#481) + +* Added sidebar methods into `shiny.experimental.ui`. `shiny.experimental.ui.layout_sidebar()` does not require `ui.panel_main()` and `ui.panel_sidebar()`. These two methods have been deprecated. `x.ui.page_navbar()`, `x.ui.navset_bar()`, `x.navset_tab_card()`, and `x.navset.pill_card()` added `sidebar=` support. (#481) + + ### Bug fixes ### Other changes diff --git a/examples/cpuinfo/app.py b/examples/cpuinfo/app.py index 7fd9329d8..59cecd5b1 100644 --- a/examples/cpuinfo/app.py +++ b/examples/cpuinfo/app.py @@ -13,7 +13,9 @@ import numpy as np import pandas as pd -from shiny import App, Inputs, Outputs, Session, reactive, render, ui +from shiny import App, Inputs, Outputs, Session +from shiny import experimental as x +from shiny import reactive, render, ui # The agg matplotlib backend seems to be a little more efficient than the default when # running on macOS, and also gives more consistent results across operating systems @@ -53,8 +55,8 @@ % f"{ncpu*4}em" ), ui.h3("CPU Usage %", class_="mt-2"), - ui.layout_sidebar( - ui.panel_sidebar( + x.ui.layout_sidebar( + x.ui.sidebar( ui.input_select( "cmap", "Colormap", @@ -69,33 +71,31 @@ ui.input_switch("hold", "Freeze output", value=False), class_="mb-3", ), - ui.panel_main( + ui.div( + {"class": "card mb-3"}, ui.div( - {"class": "card mb-3"}, - ui.div( - {"class": "card-body"}, - ui.h5({"class": "card-title mt-0"}, "Graphs"), - ui.output_plot("plot", height=f"{ncpu * 40}px"), - ), - ui.div( - {"class": "card-footer"}, - ui.input_numeric("sample_count", "Number of samples per graph", 50), - ), + {"class": "card-body"}, + ui.h5({"class": "card-title mt-0"}, "Graphs"), + ui.output_plot("plot", height=f"{ncpu * 40}px"), ), ui.div( - {"class": "card"}, - ui.div( - {"class": "card-body"}, - ui.h5({"class": "card-title m-0"}, "Heatmap"), - ), - ui.div( - {"class": "card-body overflow-auto pt-0"}, - ui.output_table("table"), - ), - ui.div( - {"class": "card-footer"}, - ui.input_numeric("table_rows", "Rows to display", 5), - ), + {"class": "card-footer"}, + ui.input_numeric("sample_count", "Number of samples per graph", 50), + ), + ), + ui.div( + {"class": "card"}, + ui.div( + {"class": "card-body"}, + ui.h5({"class": "card-title m-0"}, "Heatmap"), + ), + ui.div( + {"class": "card-body overflow-auto pt-0"}, + ui.output_table("table"), + ), + ui.div( + {"class": "card-footer"}, + ui.input_numeric("table_rows", "Rows to display", 5), ), ), ), diff --git a/examples/penguins/app.py b/examples/penguins/app.py index e6584dea9..1ebcf0645 100644 --- a/examples/penguins/app.py +++ b/examples/penguins/app.py @@ -94,10 +94,9 @@ def penguin_value_box(title: str, count: int, bgcol: str, showcase_img: str): return x.ui.value_box( title, count, - {"class_": "pt-1 pb-0"}, - showcase=x.ui.bind_fill_role( - ui.tags.img({"style": "object-fit:contain;"}, src=showcase_img), - item=True, + {"class": "pt-1 pb-0"}, + showcase=x.ui.as_fill_item( + ui.tags.img({"style": "object-fit:contain;"}, src=showcase_img) ), theme_color=None, style=f"background-color: {bgcol};", diff --git a/scripts/htmlDependencies.R b/scripts/htmlDependencies.R index e0054a601..4ef4959f2 100755 --- a/scripts/htmlDependencies.R +++ b/scripts/htmlDependencies.R @@ -2,58 +2,121 @@ versions <- list() -pak::pkg_install("rstudio/bslib") -# pak::pkg_install("cran::bslib") +# Use local lib path for installing packages so we don't pollute the user's library +withr::local_temp_libpaths() + +pak::pkg_install(c("rstudio/bslib@main", "rstudio/shiny@main", "rstudio/htmltools@main")) +# pak::pkg_install(c("cran::bslib", "cran::shiny")) versions["shiny_html_deps"] <- as.character(packageVersion("shiny")) versions["bslib"] <- as.character(packageVersion("bslib")) +versions["htmltools"] <- as.character(packageVersion("htmltools")) + +pkg_source_version <- function(pkg_name) { + pkg_info <- sessioninfo::package_info(pkg_name) + pkg_info_list <- pkg_info[pkg_info$package == pkg_name, , drop = TRUE] + pkg_info_list$source +} +write_json <- function(file, x, ..., pretty = TRUE, auto_unbox = TRUE) { + jsonlite::write_json( + c( + list("note!" = "This file is auto-generated by scripts/htmlDependencies.R"), + x + ), + file, + ..., + pretty = pretty, auto_unbox = auto_unbox + ) + +} -bslib_info <- sessioninfo::package_info("bslib") -bslib_info_list <- bslib_info[bslib_info$package == "bslib", , drop = TRUE] +bslib_version <- pkg_source_version("bslib") +shiny_version <- pkg_source_version("shiny") +htmltools_version <- pkg_source_version("htmltools") library(htmltools) library(bslib) shiny_path <- fs::path(getwd(), "shiny") www <- fs::path(shiny_path, "www") -if (fs::dir_exists(www)) fs::dir_delete(www) -fs::dir_create(www) +www_shared <- fs::path(www, "shared") +x_www <- fs::path(shiny_path, "experimental", "www") +x_www_components <- fs::path(x_www, "bslib", "components") # Copy over shiny's www/shared directory -withr::with_tempdir({ - cmd <- paste("git clone --depth 1 --branch main https://github.com/rstudio/shiny") - system(cmd) +copy_from_pkg <- function(pkg_name, pkg_dir, local_dir) { + if (fs::dir_exists(local_dir)) fs::dir_delete(local_dir) + fs::dir_create(local_dir) + + stopifnot(local_dir != ".") + + # Copy other folder into local parent folder fs::dir_copy( - "shiny/inst/www/shared", - www + system.file(pkg_dir, package = pkg_name), + dirname(local_dir) ) -}) + # Rename folder to local folder name + if (basename(local_dir) != basename(pkg_dir)) { + file.rename( + fs::path(dirname(local_dir), basename(pkg_dir)), + local_dir + ) + } + # Save pkg version info + write_json( + fs::path(local_dir, "_versions.json"), + list( + package = pkg_name, + version = pkg_source_version(pkg_name) + ) + ) +} + + +# Copy over bslib's components directory +copy_from_pkg("bslib", "components", x_www_components) +# Remove unused Sass files +fs::file_delete( + fs::dir_ls(x_www_components, type = "file", regexp = "\\.scss$") +) +# Remove unused tag require +fs::file_delete(fs::path(x_www_components, "tag-require.js")) + +# Copy over htmltools's fill directory +copy_from_pkg("htmltools", "fill", fs::path(x_www, "htmltools", "fill")) + + + + +# Copy over shiny's www/shared directory +copy_from_pkg("shiny", "www/shared", www_shared) # Don't need legacy (hopefully) -fs::dir_delete(fs::path(www, "shared", "legacy")) +fs::dir_delete(fs::path(www_shared, "legacy")) # Don't need dataTables (hopefully) -fs::dir_delete(fs::path(www, "shared", "datatables")) +fs::dir_delete(fs::path(www_shared, "datatables")) # jQuery will come in via bslib (below) fs::file_delete( - fs::dir_ls(fs::path(www, "shared"), type = "file", regexp = "jquery") + fs::dir_ls(www_shared, type = "file", regexp = "jquery") ) # Upgrade to Bootstrap 5 by default deps <- bs_theme_dependencies(bs_theme(version = 5)) withr::with_options( list(htmltools.dir.version = FALSE), - ignore <- lapply(deps, copyDependencyToDir, "shiny/www/shared") + ignore <- lapply(deps, copyDependencyToDir, www_shared) ) bs_ver <- names(bslib::versions())[bslib::versions() == "5"] versions["bootstrap"] <- bs_ver -jsonlite::write_json( +write_json( + "shiny/www/shared/bootstrap/_version.json", list( - bslib_version = bslib_info_list$source, + shiny_version = shiny_version, + bslib_version = bslib_version, + htmltools_version = htmltools_version, bootstrap_version = bs_ver - ), - "shiny/www/shared/bootstrap/version.json", - pretty = TRUE, auto_unbox = TRUE + ) ) # This additional bs3compat HTMLDependency() only holds @@ -61,11 +124,11 @@ jsonlite::write_json( # since we're generating BS5+ tab markup. Note, however, # we still do have bs3compat's CSS on the page, which # comes in via the bootstrap HTMLDependency() -fs::dir_delete(fs::path(www, "shared", "bs3compat")) +fs::dir_delete(fs::path(www_shared, "bs3compat")) requirejs_version <- "2.3.6" versions["requirejs"] <- requirejs_version -requirejs <- fs::path(www, "shared", "requirejs") +requirejs <- fs::path(www_shared, "requirejs") fs::dir_create(requirejs) download.file( paste0("https://cdnjs.cloudflare.com/ajax/libs/require.js/", requirejs_version, "/require.min.js"), @@ -94,6 +157,5 @@ cat( version_vars, "\n", version_all, - # paste0("versions = ", versions_txt), sep = "" ) diff --git a/setup.cfg b/setup.cfg index 0db8ebbe0..36d8b6ad7 100644 --- a/setup.cfg +++ b/setup.cfg @@ -77,7 +77,8 @@ dev = flake8>=6.0.0;python_version>"3.7" flake8-bugbear>=23.2.13 isort>=5.10.1 - pyright>=1.1.305 + # pyright produces break changes rapidly. Fix to a particular version + pyright==1.1.308 pre-commit>=2.15.0 wheel matplotlib diff --git a/shiny/__init__.py b/shiny/__init__.py index 4523c8373..f11052f71 100644 --- a/shiny/__init__.py +++ b/shiny/__init__.py @@ -1,6 +1,6 @@ """A package for building reactive web applications.""" -__version__ = "0.3.3.9000" +__version__ = "0.3.3.9001" from ._shinyenv import is_pyodide as _is_pyodide diff --git a/shiny/_versions.py b/shiny/_versions.py index 0d047e3a1..5f3f15dc0 100644 --- a/shiny/_versions.py +++ b/shiny/_versions.py @@ -1,11 +1,13 @@ -shiny_html_deps = "1.7.4" +shiny_html_deps = "1.7.4.9002" bslib = "0.4.2.9000" +htmltools = "0.5.5.9000" bootstrap = "5.2.2" requirejs = "2.3.6" __all__ = ( "shiny_html_deps", "bslib", + "htmltools", "bootstrap", "requirejs", ) diff --git a/shiny/examples/input_file/app.py b/shiny/examples/input_file/app.py index 7272ccc2d..318501ad2 100644 --- a/shiny/examples/input_file/app.py +++ b/shiny/examples/input_file/app.py @@ -1,15 +1,16 @@ import pandas as pd from shiny import * +from shiny import experimental as x from shiny.types import FileInfo app_ui = ui.page_fluid( - ui.layout_sidebar( - ui.panel_sidebar( + x.ui.layout_sidebar( + x.ui.sidebar( ui.input_file("file1", "Choose CSV File", accept=[".csv"], multiple=False), ui.input_checkbox("header", "Header", True), ), - ui.panel_main(ui.output_ui("contents")), + ui.output_ui("contents"), ) ) diff --git a/shiny/examples/layout_sidebar/app.py b/shiny/examples/layout_sidebar/app.py index 510b0422a..dea924b9e 100644 --- a/shiny/examples/layout_sidebar/app.py +++ b/shiny/examples/layout_sidebar/app.py @@ -2,11 +2,12 @@ import numpy as np from shiny import * +from shiny import experimental as x app_ui = ui.page_fluid( - ui.layout_sidebar( - ui.panel_sidebar(ui.input_slider("n", "N", min=0, max=100, value=20)), - ui.panel_main(ui.output_plot("plot")), + x.ui.layout_sidebar( + x.ui.sidebar(ui.input_slider("n", "N", min=0, max=100, value=20)), + ui.output_plot("plot"), ), ) diff --git a/shiny/examples/navset_hidden/app.py b/shiny/examples/navset_hidden/app.py index 91fd71a1c..ee6388a84 100644 --- a/shiny/examples/navset_hidden/app.py +++ b/shiny/examples/navset_hidden/app.py @@ -1,19 +1,18 @@ from shiny import * +from shiny import experimental as x app_ui = ui.page_fluid( - ui.layout_sidebar( - ui.panel_sidebar( + x.ui.layout_sidebar( + x.ui.sidebar( ui.input_radio_buttons( "controller", "Controller", ["1", "2", "3"], selected="1" ) ), - ui.panel_main( - ui.navset_hidden( - ui.nav(None, "Panel 1 content", value="panel1"), - ui.nav(None, "Panel 2 content", value="panel2"), - ui.nav(None, "Panel 3 content", value="panel3"), - id="hidden_tabs", - ) + ui.navset_hidden( + ui.nav(None, "Panel 1 content", value="panel1"), + ui.nav(None, "Panel 2 content", value="panel2"), + ui.nav(None, "Panel 3 content", value="panel3"), + id="hidden_tabs", ), ) ) diff --git a/shiny/examples/page_fixed/app.py b/shiny/examples/page_fixed/app.py index 7ef82b8bc..3c6b132cb 100644 --- a/shiny/examples/page_fixed/app.py +++ b/shiny/examples/page_fixed/app.py @@ -2,11 +2,12 @@ import numpy as np from shiny import * +from shiny import experimental as x app_ui = ui.page_fixed( - ui.layout_sidebar( - ui.panel_sidebar(ui.input_slider("n", "N", min=0, max=100, value=20)), - ui.panel_main(ui.output_plot("plot")), + x.ui.layout_sidebar( + x.ui.sidebar(ui.input_slider("n", "N", min=0, max=100, value=20)), + ui.output_plot("plot"), ), ) diff --git a/shiny/examples/page_fluid/app.py b/shiny/examples/page_fluid/app.py index 510b0422a..dea924b9e 100644 --- a/shiny/examples/page_fluid/app.py +++ b/shiny/examples/page_fluid/app.py @@ -2,11 +2,12 @@ import numpy as np from shiny import * +from shiny import experimental as x app_ui = ui.page_fluid( - ui.layout_sidebar( - ui.panel_sidebar(ui.input_slider("n", "N", min=0, max=100, value=20)), - ui.panel_main(ui.output_plot("plot")), + x.ui.layout_sidebar( + x.ui.sidebar(ui.input_slider("n", "N", min=0, max=100, value=20)), + ui.output_plot("plot"), ), ) diff --git a/shiny/examples/update_navs/app.py b/shiny/examples/update_navs/app.py index 1c2dea173..2381d4991 100644 --- a/shiny/examples/update_navs/app.py +++ b/shiny/examples/update_navs/app.py @@ -1,17 +1,16 @@ from shiny import * +from shiny import experimental as x app_ui = ui.page_fluid( - ui.layout_sidebar( - ui.panel_sidebar( + x.ui.layout_sidebar( + x.ui.sidebar( ui.input_slider("controller", "Controller", min=1, max=3, value=1) ), - ui.panel_main( - ui.navset_tab_card( - ui.nav("Panel 1", "Panel 1 content", value="panel1"), - ui.nav("Panel 2", "Panel 2 content", value="panel2"), - ui.nav("Panel 3", "Panel 3 content", value="panel3"), - id="inTabset", - ) + ui.navset_tab_card( + ui.nav("Panel 1", "Panel 1 content", value="panel1"), + ui.nav("Panel 2", "Panel 2 content", value="panel2"), + ui.nav("Panel 3", "Panel 3 content", value="panel3"), + id="inTabset", ), ) ) diff --git a/shiny/examples/update_slider/app.py b/shiny/examples/update_slider/app.py index 26192ef2f..ab32e7946 100644 --- a/shiny/examples/update_slider/app.py +++ b/shiny/examples/update_slider/app.py @@ -1,13 +1,14 @@ from shiny import * +from shiny import experimental as x app_ui = ui.page_fluid( - ui.layout_sidebar( - ui.panel_sidebar( + x.ui.layout_sidebar( + x.ui.sidebar( ui.tags.p("The first slider controls the second"), ui.input_slider("control", "Controller:", min=0, max=20, value=10, step=1), ui.input_slider("receive", "Receiver:", min=0, max=20, value=10, step=1), + open="always", ), - ui.panel_main(), ) ) diff --git a/shiny/experimental/e2e/accordion/app.py b/shiny/experimental/e2e/accordion/app.py new file mode 100644 index 000000000..cc305ab88 --- /dev/null +++ b/shiny/experimental/e2e/accordion/app.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import shiny.experimental as x +from shiny import App, Inputs, Outputs, Session, reactive, render, req, ui + + +def make_panel(letter: str) -> x.ui.AccordionPanel: + return x.ui.accordion_panel( + f"Section {letter}", + f"Some narrative for section {letter}", + ) + + +items = [make_panel(letter) for letter in "ABCD"] + +accordion = x.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()) + + with reactive.isolate(): + if "Section B" in acc(): + x.ui.accordion_panel_close("acc", "Section B") + else: + x.ui.accordion_panel_open("acc", "Section B") + + @reactive.Effect + def _(): + req(input.open_all()) + x.ui.accordion_panel_open("acc", True) + + @reactive.Effect + def _(): + req(input.close_all()) + x.ui.accordion_panel_close("acc", True) + + has_efg = False + has_alternate = True + + @reactive.Effect + def _(): + req(input.alternate()) + + sections = ["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] + x.ui.accordion_panel_set("acc", sections) + has_alternate = not has_alternate + + @reactive.Effect + def _(): + req(input.toggle_efg()) + + nonlocal has_efg + if has_efg: + x.ui.accordion_panel_remove("acc", ["Section E", "Section F", "Section G"]) + else: + x.ui.accordion_panel_insert("acc", make_panel("E"), "Section D") + x.ui.accordion_panel_insert("acc", make_panel("F"), "Section E") + x.ui.accordion_panel_insert("acc", make_panel("G"), "Section F") + has_efg = not has_efg + + has_updates = False + + @reactive.Effect + def _(): + req(input.toggle_updates()) + + nonlocal has_updates + if has_updates: + x.ui.accordion_panel_update( + "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) + x.ui.accordion_panel_open("acc", "Section A") + x.ui.accordion_panel_update( + "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 + + @output + @render.text + def acc_txt(): + return f"input.acc(): {input.acc()}" + + +app = App(app_ui, server) diff --git a/shiny/experimental/e2e/navbar/app.py b/shiny/experimental/e2e/navbar/app.py new file mode 100644 index 000000000..856998b88 --- /dev/null +++ b/shiny/experimental/e2e/navbar/app.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import shiny.experimental as x +from shiny import App, ui +from shiny.types import NavSetArg + +my_sidebar = x.ui.sidebar("My sidebar!!", open="open", title="Barret") + + +def nav_with_content(letter: str, prefix: str) -> ui._navs.Nav: + return ui.nav(letter, ui.markdown(f"`{prefix}`: tab {letter} content")) + + +def nav_items(prefix: str) -> list[NavSetArg]: + a = nav_with_content("a", prefix) + b = nav_with_content("b", prefix) + github = ui.nav_control( + ui.tags.a( + # ui.icon("github"), + "Shiny", + href="https://github.com/rstudio/shiny", + target="_blank", + ), + ) + space = ui.nav_spacer() + other = ui.nav_menu( + "Other links", + nav_with_content("c", prefix), + ui.nav_control( + ui.tags.a( + # icon("r-project"), + "RStudio", + href="https://rstudio.com", + target="_blank", + ), + ), + align="right", + ) + return [a, b, github, space, other] + + +app = App( + ui=x.ui.page_navbar( + # theme = bs_theme(), + *nav_items("page_navbar()"), + sidebar=my_sidebar, + title="page_navbar()", + bg="#0062cc", + inverse=True, + header=ui.markdown( + "Testing app for `bslib::nav_spacer()` and `bslib::nav_item()` [#319](https://github.com/rstudio/bslib/pull/319)." + ), + footer=ui.div( + {"style": "width:80%; margin: 0 auto"}, + ui.h4("navset_tab_card()"), + x.ui.navset_tab_card( + *nav_items("navset_tab_card()"), + sidebar=my_sidebar, + ), + ui.h4("navset_pill_card()"), + x.ui.navset_pill_card( + *nav_items("navset_pill_card()"), + sidebar=my_sidebar, + ), + # Do not include `navset_bar()` in example. Ok for testing only + ui.h4("navset_bar()"), + x.ui.navset_bar( + *nav_items("navset_bar()"), + title="Test!", + sidebar=my_sidebar, + ), + ), + ), + server=None, +) diff --git a/shiny/experimental/e2e/sidebar/app.py b/shiny/experimental/e2e/sidebar/app.py new file mode 100644 index 000000000..c258aa992 --- /dev/null +++ b/shiny/experimental/e2e/sidebar/app.py @@ -0,0 +1,90 @@ +from data import adjectives, animals, dark_color, light_color + +from shiny import App, Inputs, Outputs, Session +from shiny import experimental as x +from shiny import reactive, render, ui + +app_ui = ui.page_fixed( + ui.h1("Toggle Sidebars"), + ui.div( + ui.input_action_button("open_all", "Show all", class_="me-1"), + ui.input_action_button("close_all", "Close all", class_="me-2"), + ui.input_action_button("toggle_outer", "Toggle outer", class_="me-1"), + ui.input_action_button("toggle_inner", "Toggle inner"), + class_="my-2", + ), + x.ui.layout_sidebar( + x.ui.sidebar( + "Outer Sidebar", + ui.input_select( + "adjective", + "Adjective", + choices=adjectives, + selected=adjectives[0], + ), + id="sidebar_outer", + width=200, + bg=dark_color, + fg="white", + open="desktop", + max_height_mobile="300px", + ), + x.ui.layout_sidebar( + x.ui.sidebar( + "Inner Sidebar", + ui.input_select( + "animal", + "Animal", + choices=animals, + selected=animals[0], + ), + id="sidebar_inner", + # width=200, + bg=light_color, + open="desktop", + ), + ui.h2("Sidebar Layout"), + ui.output_ui("ui_content", tabindex=0), + id="main_inner", + border=False, + border_radius=False, + ), + id="main_outer", + height=300, + class_="p-0", + fillable=True, + ), + title="bslib | Tests | Dynamic Sidebars", +) + + +def server(input: Inputs, output: Outputs, session: Session) -> None: + @output + @render.ui + def ui_content(): + return f"Hello, {input.adjective()} {input.animal()}!" + + @reactive.Effect + @reactive.event(input.open_all) + def _(): + x.ui.sidebar_toggle("sidebar_inner", open=True) + x.ui.sidebar_toggle("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) + + @reactive.Effect + @reactive.event(input.toggle_inner) + def _(): + x.ui.sidebar_toggle("sidebar_inner") + + @reactive.Effect + @reactive.event(input.toggle_outer) + def _(): + x.ui.sidebar_toggle("sidebar_outer") + + +app = App(app_ui, server) diff --git a/shiny/experimental/e2e/sidebar/data.py b/shiny/experimental/e2e/sidebar/data.py new file mode 100644 index 000000000..d5a1768ec --- /dev/null +++ b/shiny/experimental/e2e/sidebar/data.py @@ -0,0 +1,34 @@ +import random + +dark_color, light_color = [ + ("#1A2A6C", "#AED9E0"), + ("#800020", "#F6DFD7"), + ("#4B0082", "#E6E6FA"), + ("#006D5B", "#A2D5C6"), +][random.randint(0, 3)] + +adjectives = [ + "charming", + "cuddly", + "elegant", + "fierce", + "graceful", + "majestic", + "playful", + "quirky", + "silly", + "witty", +] + +animals = [ + "elephant", + "giraffe", + "jaguar", + "koala", + "lemur", + "otter", + "panda", + "panther", + "penguin", + "zebra", +] diff --git a/shiny/experimental/examples/accordion/app.py b/shiny/experimental/examples/accordion/app.py new file mode 100644 index 000000000..5569481c4 --- /dev/null +++ b/shiny/experimental/examples/accordion/app.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import shiny.experimental as x +from shiny import App, Inputs, Outputs, Session, reactive, ui + +items = [ + x.ui.accordion_panel(f"Section {letter}", f"Some narrative for section {letter}") + for letter in "ABCDE" +] + +# # First shown by default +# x.ui.accordion(*items) + +# # Nothing shown by default +# x.ui.accordion(*items, open=False) +# # Everything shown by default +# x.ui.accordion(*items, open=True) + +# # Show particular sections +# x.ui.accordion(*items, open="Section B") +# x.ui.accordion(*items, open=["Section A", "Section B"]) + +app_ui = ui.page_fluid( + # Provide an id to create a shiny input binding + x.ui.accordion(*items, id="acc"), +) + + +def server(input: Inputs, output: Outputs, session: Session): + @reactive.Effect + def _(): + print(input.acc()) + + +app = App(app_ui, server) diff --git a/shiny/experimental/examples/accordion_panel/app.py b/shiny/experimental/examples/accordion_panel/app.py new file mode 100644 index 000000000..66fe52942 --- /dev/null +++ b/shiny/experimental/examples/accordion_panel/app.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +import shiny.experimental as x +from shiny import App, Inputs, Outputs, Session, reactive, ui + +items = [ + x.ui.accordion_panel(f"Section {letter}", f"Some narrative for section {letter}") + for letter in "ABCDE" +] + +app_ui = ui.page_fluid( + # Provide an id to create a shiny input binding + x.ui.accordion(*items, id="acc"), +) + + +def server(input: Inputs, output: Outputs, session: Session): + @reactive.Effect + def _(): + print(input.acc()) + + +app = App(app_ui, server) diff --git a/shiny/experimental/examples/accordion_panel_close/app.py b/shiny/experimental/examples/accordion_panel_close/app.py new file mode 100644 index 000000000..b2bd8e79b --- /dev/null +++ b/shiny/experimental/examples/accordion_panel_close/app.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +import shiny.experimental as x +from shiny import App, Inputs, Outputs, Session, reactive, ui + +items = [ + x.ui.accordion_panel(f"Section {letter}", f"Some narrative for section {letter}") + for letter in "ABCDE" +] + +app_ui = ui.page_fluid( + ui.input_action_button("close_acc", "Close Section C", class_="mt-3 mb-3"), + x.ui.accordion(*items, id="acc", multiple=True), +) + + +def server(input: Inputs, output: Outputs, session: Session): + @reactive.Effect + @reactive.event(input.close_acc) + def _(): + x.ui.accordion_panel_close("acc", "Section C") + + +app = App(app_ui, server) diff --git a/shiny/experimental/examples/accordion_panel_insert/app.py b/shiny/experimental/examples/accordion_panel_insert/app.py new file mode 100644 index 000000000..e2f3d7104 --- /dev/null +++ b/shiny/experimental/examples/accordion_panel_insert/app.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +import random + +import shiny.experimental as x +from shiny import App, Inputs, Outputs, Session, reactive, ui + + +def make_panel(letter: str) -> x.ui.AccordionPanel: + return x.ui.accordion_panel( + f"Section {letter}", f"Some narrative for section {letter}" + ) + + +items = [make_panel(letter) for letter in "ABCDE"] + +app_ui = ui.page_fluid( + ui.input_action_button("add_panel", "Add random panel", class_="mt-3 mb-3"), + x.ui.accordion(*items, id="acc", multiple=True), +) + + +def server(input: Inputs, output: Outputs, session: Session): + @reactive.Effect + @reactive.event(input.add_panel) + def _(): + x.ui.accordion_panel_insert("acc", make_panel(str(random.randint(0, 10000)))) + + +app = App(app_ui, server) diff --git a/shiny/experimental/examples/accordion_panel_open/app.py b/shiny/experimental/examples/accordion_panel_open/app.py new file mode 100644 index 000000000..93c77d4f5 --- /dev/null +++ b/shiny/experimental/examples/accordion_panel_open/app.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +import shiny.experimental as x +from shiny import App, Inputs, Outputs, Session, reactive, ui + +items = [ + x.ui.accordion_panel(f"Section {letter}", f"Some narrative for section {letter}") + for letter in "ABCDE" +] + +app_ui = ui.page_fluid( + ui.input_action_button("open_acc", "Open Section C", class_="mt-3 mb-3"), + x.ui.accordion(*items, id="acc", multiple=True), +) + + +def server(input: Inputs, output: Outputs, session: Session): + @reactive.Effect + @reactive.event(input.open_acc) + def _(): + x.ui.accordion_panel_open("acc", "Section C") + + +app = App(app_ui, server) diff --git a/shiny/experimental/examples/accordion_panel_remove/app.py b/shiny/experimental/examples/accordion_panel_remove/app.py new file mode 100644 index 000000000..79cbfd1fd --- /dev/null +++ b/shiny/experimental/examples/accordion_panel_remove/app.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import random + +import shiny.experimental as x +from shiny import App, Inputs, Outputs, Session, reactive, ui + + +def make_panel(letter: str) -> x.ui.AccordionPanel: + return x.ui.accordion_panel( + f"Section {letter}", f"Some narrative for section {letter}" + ) + + +items = [make_panel(letter) for letter in "ABCDE"] + +choices = ["A", "B", "C", "D", "E"] +random.shuffle(choices) + +app_ui = ui.page_fluid( + ui.input_action_button( + "remove_panel", + f"Remove Section {choices[-1]}", + class_="mt-3 mb-3", + ), + x.ui.accordion(*items, id="acc", multiple=True), +) + + +def server(input: Inputs, output: Outputs, session: Session): + @reactive.Effect + @reactive.event(input.remove_panel) + def _(): + if len(choices) == 0: + ui.notification_show("No more panels to remove!") + return + + # Remove panel + x.ui.accordion_panel_remove("acc", f"Section { choices.pop() }") + + label = "No more panels to remove!" + if len(choices) > 0: + label = f"Remove Section {choices[-1]}" + ui.update_action_button("remove_panel", label=label) + + +app = App(app_ui, server) diff --git a/shiny/experimental/examples/accordion_panel_set/app.py b/shiny/experimental/examples/accordion_panel_set/app.py new file mode 100644 index 000000000..58c847207 --- /dev/null +++ b/shiny/experimental/examples/accordion_panel_set/app.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +import shiny.experimental as x +from shiny import App, Inputs, Outputs, Session, reactive, ui + +items = [ + x.ui.accordion_panel(f"Section {letter}", f"Some narrative for section {letter}") + for letter in "ABCDE" +] + +app_ui = ui.page_fluid( + ui.input_action_button("set_acc", "Only open sections A,C,E", class_="mt-3 mb-3"), + # Provide an id to create a shiny input binding + x.ui.accordion(*items, id="acc", open=["Section B", "Section D"], multiple=True), +) + + +def server(input: Inputs, output: Outputs, session: Session): + @reactive.Effect + @reactive.event(input.set_acc) + def _(): + x.ui.accordion_panel_set("acc", ["Section A", "Section C", "Section E"]) + + +app = App(app_ui, server) diff --git a/shiny/experimental/examples/accordion_panel_update/app.py b/shiny/experimental/examples/accordion_panel_update/app.py new file mode 100644 index 000000000..49fda87c2 --- /dev/null +++ b/shiny/experimental/examples/accordion_panel_update/app.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import shiny.experimental as x +from shiny import App, Inputs, Outputs, Session, reactive, ui + + +def make_panel(letter: str) -> x.ui.AccordionPanel: + return x.ui.accordion_panel( + f"Section {letter}", + f"Some narrative for section {letter}", + value=f"sec_{letter}", + ) + + +items = [make_panel(letter) for letter in "ABCDE"] + +app_ui = ui.page_fluid( + ui.input_switch("update_panel", "Update Sections"), + x.ui.accordion(*items, id="acc", multiple=True), +) + + +def server(input: Inputs, output: Outputs, session: Session): + @reactive.Effect + @reactive.event(input.update_panel) + def _(): + txt = " (updated)" if input.update_panel() else "" + for letter in "ABCDE": + x.ui.accordion_panel_update( + "acc", + f"sec_{letter}", + f"Some{txt} narrative for section {letter}", + title=f"Section {letter}{txt}", + ) + + +app = App(app_ui, server) diff --git a/shiny/experimental/examples/layout_sidebar/app.py b/shiny/experimental/examples/layout_sidebar/app.py new file mode 100644 index 000000000..0a9c5cc6f --- /dev/null +++ b/shiny/experimental/examples/layout_sidebar/app.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +import shiny.experimental as x +from shiny import App, ui + +app_ui = ui.page_fluid( + x.ui.layout_sidebar( + x.ui.sidebar("Sidebar content"), + "Main content", + ) +) + + +app = App(app_ui, server=None) diff --git a/shiny/experimental/examples/sidebar/app.py b/shiny/experimental/examples/sidebar/app.py new file mode 100644 index 000000000..45c54a698 --- /dev/null +++ b/shiny/experimental/examples/sidebar/app.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import shiny.experimental as x +from shiny import App, Inputs, Outputs, Session, render, ui + +app_ui = ui.page_fluid( + ui.br(), + x.ui.layout_sidebar( + x.ui.sidebar("Left sidebar content", id="sidebar_left"), + ui.output_text_verbatim("state_left"), + ), + ui.br(), + x.ui.layout_sidebar( + x.ui.sidebar("Right sidebar content", id="sidebar_right", position="right"), + ui.output_text_verbatim("state_right"), + ), + ui.br(), + x.ui.layout_sidebar( + x.ui.sidebar("Closed sidebar content", id="sidebar_closed", open="closed"), + ui.output_text_verbatim("state_closed"), + ), + ui.br(), + x.ui.layout_sidebar( + x.ui.sidebar("Always sidebar content", id="sidebar_always", open="always"), + ui.output_text_verbatim("state_always"), + ), +) + + +def server(input: Inputs, output: Outputs, session: Session): + @output + @render.text + def state_left(): + return f"input.sidebar_left(): {input.sidebar_left()}" + + @output + @render.text + def state_right(): + return f"input.sidebar_right(): {input.sidebar_right()}" + + @output + @render.text + def state_closed(): + return f"input.sidebar_closed(): {input.sidebar_closed()}" + + @output + @render.text + def state_always(): + return f"input.sidebar_always(): {input.sidebar_always()}" + + +app = App(app_ui, server) diff --git a/shiny/experimental/examples/sidebar_toggle/app.py b/shiny/experimental/examples/sidebar_toggle/app.py new file mode 100644 index 000000000..acaaee263 --- /dev/null +++ b/shiny/experimental/examples/sidebar_toggle/app.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +import shiny.experimental as x +from shiny import App, Inputs, Outputs, Session, reactive, render, ui + +app_ui = ui.page_fluid( + x.ui.layout_sidebar( + x.ui.sidebar("Sidebar content", id="sidebar"), + ui.input_action_button("sidebar_toggle", label="Toggle sidebar"), + ui.br(), + ui.output_text_verbatim("state"), + ) +) + + +def server(input: Inputs, output: Outputs, session: Session): + @reactive.Effect + @reactive.event(input.sidebar_toggle) + def _(): + x.ui.sidebar_toggle("sidebar") + + @output + @render.text + def state(): + return f"input.sidebar(): {input.sidebar()}" + + +app = App(app_ui, server=server) diff --git a/shiny/experimental/ui/__init__.py b/shiny/experimental/ui/__init__.py index 3b860d14f..1be212b8a 100644 --- a/shiny/experimental/ui/__init__.py +++ b/shiny/experimental/ui/__init__.py @@ -1,5 +1,6 @@ -from ._sidebar import layout_sidebar, sidebar -from ._page import page_fillable +from ._sidebar import layout_sidebar, sidebar, sidebar_toggle, panel_main, panel_sidebar +from ._page import page_fillable, page_navbar +from ._navs import navset_bar, navset_tab_card, navset_pill_card from ._card_item import ( CardItem, card_header, @@ -10,20 +11,52 @@ from ._card import card from ._layout import layout_column_wrap from ._valuebox import value_box -from ._fill import bind_fill_role + +from ._fill import ( + FillingLayout, + # bind_fill_role, + as_fill_carrier, + as_fillable_container, + as_fill_item, + remove_all_fill, + is_fill_carrier, + is_fillable_container, + is_fill_item, +) + from ._output import ( output_image, output_plot, output_ui, ) from ._input_text import input_text_area +from ._accordion import ( + AccordionPanel, + accordion, + accordion_panel, + accordion_panel_set, + accordion_panel_open, + accordion_panel_close, + accordion_panel_insert, + accordion_panel_remove, + accordion_panel_update, +) + __all__ = ( # Sidebar "layout_sidebar", "sidebar", + "sidebar_toggle", + "panel_main", + "panel_sidebar", # Page "page_fillable", + "page_navbar", + # Navs + "navset_bar", + "navset_tab_card", + "navset_pill_card", # Card "CardItem", "card", @@ -36,11 +69,29 @@ # ValueBox "value_box", # Fill - "bind_fill_role", + "FillingLayout", + # "bind_fill_role", + "as_fill_carrier", + "as_fillable_container", + "as_fill_item", + "remove_all_fill", + "is_fill_carrier", + "is_fillable_container", + "is_fill_item", # Output "output_image", "output_plot", "output_ui", # input_text_area "input_text_area", + # Accordion + "AccordionPanel", + "accordion", + "accordion_panel", + "accordion_panel_set", + "accordion_panel_open", + "accordion_panel_close", + "accordion_panel_insert", + "accordion_panel_remove", + "accordion_panel_update", ) diff --git a/shiny/experimental/ui/_accordion.py b/shiny/experimental/ui/_accordion.py new file mode 100644 index 000000000..14c82d1db --- /dev/null +++ b/shiny/experimental/ui/_accordion.py @@ -0,0 +1,428 @@ +from __future__ import annotations + +import random +from typing import Optional, TypeVar + +from htmltools import Tag, TagAttrs, TagAttrValue, TagChild, css, tags + +from ..._typing_extensions import Literal +from ..._utils import drop_none +from ...session import Session, require_active_session +from ...types import MISSING, MISSING_TYPE +from ._css import CssUnit, validate_css_unit +from ._htmldeps import accordion_dependency +from ._utils import consolidate_attrs + + +class AccordionPanel: + _args: tuple[TagChild | TagAttrs, ...] + _kwargs: dict[str, TagAttrValue] + + _data_value: str # Read within `accordion()` + _icon: TagChild | None + _title: TagChild | None + _id: str | None + + _is_open: bool # Set within `accordion()` + _is_multiple: bool # Set within `accordion()` + + def __init__( + self, + *args: TagChild | TagAttrs, + data_value: str, + icon: TagChild | None, + title: TagChild | None, + id: str | None, + **kwargs: TagAttrValue, + ): + self._args = args + self._data_value = data_value + self._icon = icon + self._title = title + self._id = id + self._kwargs = kwargs + self._is_multiple = False + self._is_open = True + + def resolve(self) -> Tag: + btn_attrs = {} + if self._is_open: + btn_attrs["aria-expanded"] = "true" + else: + btn_attrs["class"] = "collapsed" + btn_attrs["aria-expanded"] = "false" + + if not self._is_multiple: + btn_attrs["data-bs-parent"] = f"#{self._id}" + + btn = tags.button( + { + "class": "accordion-button", + "type": "button", + "data-bs-toggle": "collapse", + "data-bs-target": f"#{self._id}", + "aria-controls": self._id, + }, + btn_attrs, + # Always include an .accordion-icon container to simplify accordion_panel_update() logic + tags.div({"class": "accordion-icon"}, self._icon), + tags.div({"class": "accordion-title"}, self._title), + ) + + return tags.div( + { + "class": "accordion-item", + "data-value": self._data_value, + }, + # Use a instead of

so that it doesn't get included in rmd/pkgdown/qmd TOC + # TODO-bslib: can we provide a way to put more stuff in the header? Like maybe some right-aligned controls? + tags.span( + {"class": "accordion-header h2"}, + btn, + ), + tags.div( + { + "id": self._id, + "class": "accordion-collapse collapse", + }, + {"class": "show"} if self._is_open else None, + tags.div({"class": "accordion-body"}, *self._args, **self._kwargs), + ), + ) + + def tagify(self) -> Tag: + return self.resolve().tagify() + + +# Create a vertically collapsing accordion +# +# @param ... Named arguments become attributes on the `
` +# element. Unnamed arguments should be `accordion_panel()`s. +# @param id If provided, you can use `input$id` in your server logic to +# determine which of the `accordion_panel()`s are currently active. The value +# will correspond to the `accordion_panel()`'s `value` argument. +# @param open A character vector of `accordion_panel()` `value`s to open +# (i.e., show) by default. The default value of `NULL` will open the first +# `accordion_panel()`. Use a value of `TRUE` to open all (or `FALSE` to +# open none) of the items. It's only possible to open more than one panel +# when `multiple=TRUE`. +# @param multiple Whether multiple `accordion_panel()` can be `open` at once. +# @param class Additional CSS classes to include on the accordion div. +# @param width,height Any valid CSS unit; for example, height="100%". +# +# @references +# +# @export +# @seealso [accordion_panel_set()] +# @examples +# +# items <- lapply(LETTERS, function(x) { +# accordion_panel(paste("Section", x), paste("Some narrative for section", x)) +# }) +# +# # First shown by default +# accordion(!!!items) +# # Nothing shown by default +# accordion(!!!items, open = FALSE) +# # Everything shown by default +# accordion(!!!items, open = TRUE) +# +# # Show particular sections +# accordion(!!!items, open = "Section B") +# accordion(!!!items, open = c("Section A", "Section B")) +# +# # Provide an id to create a shiny input binding +# if (interactive()) { +# library(shiny) +# +# ui <- page_fluid( +# accordion(!!!items, id = "acc") +# ) +# +# server <- function(input, output) { +# observe(print(input$acc)) +# } +# +# shinyApp(ui, server) +# } +# +def accordion( + *args: AccordionPanel | TagAttrs, + id: Optional[str] = None, + open: Optional[bool | str | list[str]] = None, + multiple: bool = True, + class_: Optional[str] = None, + width: Optional[CssUnit] = None, + height: Optional[CssUnit] = None, + **kwargs: TagAttrValue, +) -> Tag: + # TODO-bookmarking: Restore input here + # open = restore_input(id = id, default = open) + + attrs, panels = consolidate_attrs(*args, class_=class_, **kwargs) + for panel in panels: + if not isinstance(panel, AccordionPanel): + raise TypeError( + "All `accordion(*args)` must be of type `AccordionPanel` which can be created using `accordion_panel()`" + ) + + is_open: list[bool] = [] + if open is None: + is_open = [False for _ in panels] + elif isinstance(open, bool): + is_open = [open for _ in panels] + else: + if not isinstance(open, list): + open = [open] + # + is_open = [panel._data_value in open for panel in panels] + + # Open the first panel by default + if open is not False and len(is_open) > 0 and not any(is_open): + is_open[0] = True + + if (not multiple) and sum(is_open) > 1: + raise ValueError("Can't select more than one panel when `multiple = False`") + + # Since multiple=False requires an id, we always include one, + # but only create a binding when it is provided + binding_class_value: TagAttrs | None = None + if id is None: + id = f"bslib-accordion-{random.randint(1000, 10000)}" + binding_class_value = None + else: + binding_class_value = {"class": "bslib-accordion-input"} + + for panel, open in zip(panels, is_open): + panel._is_multiple = multiple + panel._is_open = open + + panel_tags = [panel.resolve() for panel in panels] + + tag = tags.div( + { + "id": id, + "class": "accordion", + "style": css( + width=validate_css_unit(width), height=validate_css_unit(height) + ), + }, + # just for ease of identifying autoclosing client-side + {"class": "autoclose"} if not multiple else None, + binding_class_value, + accordion_dependency(), + attrs, + *panel_tags, + ) + return tag + + +# @rdname accordion +# @param title A title to appear in the `accordion_panel()`'s header. +# @param value A character string that uniquely identifies this panel. +# @param icon A [htmltools::tag] child (e.g., [bsicons::bs_icon()]) which is positioned just before the `title`. +# @export +def accordion_panel( + title: TagChild, + *args: TagChild | TagAttrs, + value: Optional[str] | MISSING_TYPE = MISSING, + icon: Optional[TagChild] = None, + **kwargs: TagAttrValue, +) -> AccordionPanel: + if value is MISSING: + if isinstance(title, str): + value = title + else: + raise ValueError("If `title` is not a string, `value` must be provided") + value = title + if not isinstance(value, str): + raise TypeError("`value` must be a string") + + id = f"bslib-accordion-panel-{random.randint(1000, 10000)}" + + return AccordionPanel( + *args, + data_value=value, + icon=icon, + title=title, + id=id, + **kwargs, + ) + + +# Send message before the next flush since things like remove/insert may +# remove/create input/output values. Also do this for set/open/close since, +# you might want to open a panel after inserting it. +def _send_panel_message( + id: str, + session: Session | None, + **kwargs: object, +) -> None: + message = drop_none(kwargs) + session = require_active_session(session) + session.on_flush(lambda: session.send_input_message(id, message), once=True) + + +# Dynamically update accordions +# +# Dynamically (i.e., programmatically) update/modify [`accordion()`]s in a +# Shiny app. These functions require an `id` to be provided to the +# `accordion()` and must also be called within an active Shiny session. +# +# @param id an character string that matches an existing [accordion()]'s `id`. +# @param values either a character string (used to identify particular +# [accordion_panel()](s) by their `value`) or `TRUE` (i.e., all `values`). +# @param session a shiny session object (the default should almost always be +# used). +# +# @describeIn accordion_panel_set same as `accordion_panel_open()`, except it +# also closes any currently open panels. +# @export +def _accordion_panel_action( + *, + id: str, + method: str, + values: bool | str | list[str], + session: Session | None, +) -> None: + if not isinstance(values, bool): + if not isinstance(values, list): + values = [values] + _assert_list_str(values) + + _send_panel_message( + id, + session, + method=method, + values=values, + ) + + +def accordion_panel_set( + id: str, + values: bool | str | list[str], + session: Optional[Session] = None, +) -> None: + _accordion_panel_action(id=id, method="set", values=values, session=session) + + +# @describeIn accordion_panel_set open [accordion_panel()]s. +# @export +def accordion_panel_open( + id: str, + values: bool | str | list[str], + session: Optional[Session] = None, +) -> None: + _accordion_panel_action(id=id, method="open", values=values, session=session) + + +# @describeIn accordion_panel_set close [accordion_panel()]s. +# @export +def accordion_panel_close( + id: str, + values: bool | str | list[str], + session: Optional[Session] = None, +) -> None: + _accordion_panel_action(id=id, method="close", values=values, session=session) + + +# @param panel an [accordion_panel()]. +# @param target The `value` of an existing panel to insert next to. If +# removing: the `value` of the [accordion_panel()] to remove. +# @param position Should `panel` be added before or after the target? When +# `target` is `NULL` (the default), `"after"` will append after the last +# panel and `"before"` will prepend before the first panel. +# +# @describeIn accordion_panel_set insert a new [accordion_panel()] +# @export +def accordion_panel_insert( + id: str, + panel: AccordionPanel, + target: Optional[str] = None, + position: Literal["after", "before"] = "after", + session: Optional[Session] = None, +) -> None: + if position not in ("after", "before"): + raise ValueError("`position` must be either 'after' or 'before'") + session = require_active_session(session) + _send_panel_message( + id, + session, + method="insert", + panel=session._process_ui(panel.resolve()), + target=None if target is None else _assert_str(target), + position=position, + ) + + +# @describeIn accordion_panel_set remove [accordion_panel()]s. +# @export +def accordion_panel_remove( + id: str, + target: str | list[str], + session: Optional[Session] = None, +) -> None: + if not isinstance(target, list): + target = [target] + + _send_panel_message( + id, + session, + method="remove", + target=_assert_list_str(target), + ) + + +T = TypeVar("T") + + +def _missing_none_x(x: T | None | MISSING_TYPE) -> T | Literal[""] | None: + if isinstance(x, MISSING_TYPE): + return None + if x is None: + return "" + return x + + +# @describeIn accordion_panel_set update a [accordion_panel()]. +# @inheritParams accordion_panel +# @export +def accordion_panel_update( + id: str, + target: str, + *body: TagChild, + title: TagChild | None | MISSING_TYPE = MISSING, + value: str | None | MISSING_TYPE = MISSING, + icon: TagChild | None | MISSING_TYPE = MISSING, + session: Optional[Session] = None, +) -> None: + session = require_active_session(session) + + title = _missing_none_x(title) + value = _missing_none_x(value) + icon = _missing_none_x(icon) + _send_panel_message( + id, + session, + method="update", + target=_assert_str(target), + value=None if value is None else _assert_str(value), + body=None if len(body) == 0 else session._process_ui(body), + title=None if title is None else session._process_ui(title), + icon=None if icon is None else session._process_ui(icon), + ) + + +def _assert_str(x: str) -> str: + if not isinstance(x, str): + raise TypeError(f"Expected str, got {type(x)}") + return x + + +def _assert_list_str(x: list[str]) -> list[str]: + if not isinstance(x, list): + raise TypeError(f"Expected list, got {type(x)}") + for i, x_i in enumerate(x): + if not isinstance(x_i, str): + raise TypeError(f"Expected str in x[{i}], got {type(x_i)}") + return x diff --git a/shiny/experimental/ui/_card.py b/shiny/experimental/ui/_card.py index 24856e7b6..12967e476 100644 --- a/shiny/experimental/ui/_card.py +++ b/shiny/experimental/ui/_card.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import NamedTuple, Optional +from typing import Optional from htmltools import Tag, TagAttrs, TagAttrValue, TagChild, css, div, tags @@ -9,30 +9,8 @@ from ._card_item import CardItem, WrapperCallable, card_body, wrap_children_in_card from ._css import CssUnit, validate_css_unit from ._fill import bind_fill_role - -# class Page: -# x: Tag -# def __init__( -# self, -# x: Tag, -# ): -# self.x = x - - -# class Fragment: -# x: Tag -# page: Page -# def __init__( -# self, -# x: Tag, -# page: Page, -# ): -# self.x = x -# self.page = page - - -# def as_fragment(x: Tag, page: Page) -> Fragment: -# return Fragment(x=x, page=page) +from ._htmldeps import card_dependency +from ._utils import consolidate_attrs # A Bootstrap card component @@ -95,98 +73,37 @@ def card( height: Optional[CssUnit] = None, max_height: Optional[CssUnit] = None, fill: bool = True, - class_: Optional[str] = None, # Applies after `bind_fill_role()` + class_: Optional[str] = None, wrapper: WrapperCallable | None | MISSING_TYPE = MISSING, **kwargs: TagAttrValue, ) -> Tag: if isinstance(wrapper, MISSING_TYPE): wrapper = card_body - children, attrs = separate_args_into_children_and_attrs(*args) + attrs, children = consolidate_attrs(*args, class_=class_, **kwargs) children = wrap_children_in_card(*children, wrapper=wrapper) tag = div( - *children, - *attrs, - full_screen_toggle() if full_screen else None, - card_js_init(), { "class": "card bslib-card", "style": css( height=validate_css_unit(height), max_height=validate_css_unit(max_height), ), + "data-bslib-card-init": True, }, - **kwargs, + *children, + attrs, + full_screen_toggle() if full_screen else None, + card_dependency(), + card_js_init(), ) - tag = bind_fill_role(tag, container=True, item=fill) - # Give the user an opportunity to override the classes added by bind_fill_role() - if class_ is not None: - tag.add_class(class_) - return tag - - -class ChildrenAndAttrs(NamedTuple): - children: list[TagChild | CardItem] - attrs: list[TagAttrs] - - -def separate_args_into_children_and_attrs( - *args: TagChild | TagAttrs | CardItem, -) -> ChildrenAndAttrs: - children: list[TagChild | CardItem] = [] - attrs: list[TagAttrs] = [] - - for arg in args: - if isinstance(arg, dict): - attrs.append(arg) - else: - children.append(arg) - - return ChildrenAndAttrs(children, attrs) + return bind_fill_role(tag, container=True, item=fill) def card_js_init() -> Tag: return tags.script( - {"data-bslib-card-needs-init": True}, - """\ - var thisScript = document.querySelector('script[data-bslib-card-needs-init]'); - if (!thisScript) throw new Error('Failed to register card() resize observer'); - - thisScript.removeAttribute('data-bslib-card-needs-init'); - - var card = $(thisScript).parents('.card').last(); - if (!card) throw new Error('Failed to register card() resize observer'); - - // Let Shiny know to trigger resize when the card size changes - // TODO: shiny could/should do this itself (rstudio/shiny#3682) - var resizeEvent = window.document.createEvent('UIEvents'); - resizeEvent.initUIEvent('resize', true, false, window, 0); - var ro = new ResizeObserver(() => { window.dispatchEvent(resizeEvent); }); - ro.observe(card[0]); - - // Enable tooltips (for the expand icon) - var tooltipList = card[0].querySelectorAll('[data-bs-toggle=\"tooltip\"]'); - tooltipList.forEach(function(x) { new bootstrap.Tooltip(x); }); - - // In some complex fill-based layouts with multiple outputs (e.g., plotly), - // shiny initializes with the correct sizing, but in-between the 1st and last - // renderValue(), the size of the output containers can change, meaning every - // output but the 1st gets initialized with the wrong size during their - // renderValue(); and then after the render phase, shiny won't know trigger a - // resize since all the widgets will return to their original size - // (and thus, Shiny thinks there isn't any resizing to do). - // We workaround that situation by manually triggering a resize on the binding - // when the output container changes (this way, if the size is different during - // the render phase, Shiny will know about it) - $(document).on('shiny:value', function(x) { - var el = x.binding.el; - if (card[0].contains(el) && !$(el).data('bslib-output-observer')) { - var roo = new ResizeObserver(x.binding.onResize); - roo.observe(el); - $(el).data('bslib-output-observer', true); - } - }); - """, + {"data-bslib-card-init": True}, + "window.bslib.Card.initializeAllCards();", ) diff --git a/shiny/experimental/ui/_card_full_screen.py b/shiny/experimental/ui/_card_full_screen.py index c409e761a..5d0055496 100644 --- a/shiny/experimental/ui/_card_full_screen.py +++ b/shiny/experimental/ui/_card_full_screen.py @@ -2,8 +2,6 @@ from htmltools import HTML, Tag, tags -from ._htmldeps import card_full_screen_dep - def full_screen_toggle() -> Tag: return tags.span( @@ -14,7 +12,6 @@ def full_screen_toggle() -> Tag: "title": "Expand", }, full_screen_toggle_icon(), - card_full_screen_dep(), ) diff --git a/shiny/experimental/ui/_card_item.py b/shiny/experimental/ui/_card_item.py index ecae14a8b..05cb57ddb 100644 --- a/shiny/experimental/ui/_card_item.py +++ b/shiny/experimental/ui/_card_item.py @@ -6,14 +6,12 @@ from pathlib import Path, PurePath from typing import Optional -from htmltools import Tag, TagAttrs, TagAttrValue, TagChild, css, tags +from htmltools import Tag, TagAttrs, TagAttrValue, TagChild, TagList, css, tags from ..._typing_extensions import Literal, Protocol from ...types import MISSING, MISSING_TYPE from ._css import CssUnit, validate_css_unit -from ._fill import bind_fill_role - -# T = TypeVar("T", bound=Tagifiable) +from ._fill import as_fill_carrier, bind_fill_role class CardItem: @@ -23,11 +21,11 @@ def __init__( ): self._x = x - def get_item(self) -> TagChild: + def resolve(self) -> TagChild: return self._x - # def tagify(self) -> TagList | Tag | MetadataNode | str: - # return self._x.tagify() + def tagify(self) -> TagList: + return TagList(self._x).tagify() # Card items @@ -67,15 +65,12 @@ def card_body( height: Optional[CssUnit] = None, gap: Optional[CssUnit] = None, fill: bool = True, - class_: Optional[str] = None, # Applies after `bind_fill_role()` + class_: Optional[str] = None, **kwargs: TagAttrValue, ) -> CardItem: if isinstance(max_height_full_screen, MISSING_TYPE): max_height_full_screen = max_height - if fillable: - # TODO-future: Make sure shiny >= v1.7.4 - # TODO-future: Make sure htmlwidgets >= 1.6.0 - ... + div_style_args = { "min-height": validate_css_unit(min_height), "--bslib-card-body-max-height": validate_css_unit(max_height), @@ -95,16 +90,13 @@ def card_body( "class": "card-body", "style": css(**div_style_args), }, + class_=class_, **kwargs, ) - tag = bind_fill_role(tag, item=fill, container=fillable) - - # Give the user an opportunity to override the classes added by bind_fill_role() - if class_ is not None: - tag.add_class(class_) - - return CardItem(tag) + return CardItem( + bind_fill_role(tag, item=fill, container=fillable), + ) # https://mypy.readthedocs.io/en/stable/protocols.html#callback-protocols @@ -159,7 +151,7 @@ def wrap_children(): def card_items_to_tag_children(card_items: list[CardItem]) -> list[TagChild]: - return [card_item.get_item() for card_item in card_items] + return [card_item.resolve() for card_item in card_items] def wrap_children_in_card( @@ -188,10 +180,10 @@ def __call__( def card_title( *args: TagChild | TagAttrs, - _container: TagCallable = tags.h5, + container: TagCallable = tags.h5, **kwargs: TagAttrValue, ) -> Tag: - return _container(*args, **kwargs) + return container(*args, **kwargs) # @describeIn card_body A header (with border and background color) for the `card()`. Typically appears before a `card_body()`. @@ -241,10 +233,11 @@ def card_image( href: Optional[str] = None, border_radius: Literal["top", "bottom", "all", "none"] = "top", mime_type: Optional[str] = None, - class_: Optional[str] = None, # Applies after `bind_fill_role()` + class_: Optional[str] = None, height: Optional[CssUnit] = None, fill: bool = True, width: Optional[CssUnit] = None, + # Required so that multiple `card_images()` are not put in the same `card()` container: ImgContainer = card_body, **kwargs: TagAttrValue, ) -> CardItem: @@ -282,18 +275,16 @@ def card_image( }, {"class": card_class_map.get(border_radius, None)}, *args, + class_=class_, **kwargs, ) image = bind_fill_role(image, item=fill) - # Give the user an opportunity to override the classes added by bind_fill_role() - if class_ is not None: - image.add_class(class_) if href is not None: - image = bind_fill_role(tags.a(image, href=href), container=True, item=True) + image = as_fill_carrier(tags.a(image, href=href)) - if callable(container): + if container: return container(image) else: return CardItem(image) diff --git a/shiny/experimental/ui/_color.py b/shiny/experimental/ui/_color.py index 7c32c06f6..d7c75c8da 100644 --- a/shiny/experimental/ui/_color.py +++ b/shiny/experimental/ui/_color.py @@ -1,3 +1,3 @@ def get_color_contrast(color: str) -> str: - # TODO: Implement + # TODO-future: Implement return color diff --git a/shiny/experimental/ui/_css.py b/shiny/experimental/ui/_css.py index 0fb86a5fb..dcd64ef9d 100644 --- a/shiny/experimental/ui/_css.py +++ b/shiny/experimental/ui/_css.py @@ -1,11 +1,8 @@ from __future__ import annotations -import numbers from typing import Union, overload CssUnit = Union[ - # TODO: pylance really doesn't like `numbers.Number`. - # Instead, use `int` and `float` int, float, str, @@ -44,13 +41,7 @@ def validate_css_unit(value: CssUnit) -> str: def validate_css_unit(value: None | CssUnit) -> None | str: # TODO-future: Actually validate. Or don't validate, but then change # the function name to to_css_unit() or something. - # TODO-future: pylance can't figure out if an `int` or `float` is a `numbers.Number` (which - # is it). For now, use the extra types - if ( - isinstance(value, numbers.Number) - or isinstance(value, float) - or isinstance(value, int) - ): + if isinstance(value, (float, int)): # Explicit check for 0 because floats may format to have many decimals. if value == 0: return "0" diff --git a/shiny/experimental/ui/_fill.py b/shiny/experimental/ui/_fill.py index 8711e0e95..b1296ae80 100644 --- a/shiny/experimental/ui/_fill.py +++ b/shiny/experimental/ui/_fill.py @@ -1,16 +1,31 @@ from __future__ import annotations -from typing import Optional +from typing import Optional, TypeVar -from htmltools import Tag +from htmltools import Tag, TagChild, Tagifiable -from ._htmldeps import fill_dependencies +from ..._typing_extensions import Literal, Protocol, runtime_checkable +from ._css import CssUnit, validate_css_unit +from ._tag import tag_add_style, tag_prepend_class, tag_remove_class +__all__ = ( + "bind_fill_role", + "as_fill_carrier", + "as_fillable_container", + "as_fill_item", + "remove_all_fill", + "is_fill_carrier", + "is_fillable_container", + "is_fill_item", +) -# TODO-future: Find a way to allow users to pass `class_` within `**kwargs`, rather than -# manually handling it so that it can override the classes added by `bind_fill_role()`. -# Ex: `card_body()`, `card_image()`, `card()`, `layout_column_wrap()` and by extension `value_box()` or any method that calls the first four -# TODO-future: +TagT = TypeVar("TagT", bound="Tag") + + +fill_item_class = "html-fill-item" +fill_container_class = "html-fill-container" + +# TODO-future-approach: bind_fill_role() should return None? # From @wch: # > For functions like this, which modify the original object, I think the Pythonic way # > of doing things is to return None, to make it clearer that the object is modified in @@ -18,28 +33,304 @@ # From @schloerke: # > It makes for a very clunky interface. Keeping as is for now. # > Should we copy the tag before modifying it? (If we are not doing that elsewhere, then I am hesitant to start here.) +# > If it is not utilizing `nonlocal foo`, then it should be returned. Even if it is altered in-place + + +# Allow tags to intelligently fill their container +# +# Create fill containers and items. If a fill item is a direct child of a fill +# container, and that container has an opinionated height, then the item is +# allowed to grow and shrink to its container's size. +# +# @param x a [tag()] object. Can also be a valid [tagQuery()] input if +# `.cssSelector` is specified. +# @param ... currently unused. +# @param item whether or not to treat `x` as a fill item. +# @param container whether or not to treat `x` as a fill container. Note this +# will the CSS `display` property on the tag to `flex`, which changes the way +# it does layout of it's direct children. Thus, one should be careful not to +# mark a tag as a fill container when it needs to rely on other `display` +# behavior. +# @param overwrite whether or not to override previous calls to +# `bindFillRole()` (e.g., to remove the item/container role from a tag). +# @param .cssSelector A character string containing a CSS selector for +# targeting particular (inner) tag(s) of interest. For more details on what +# selector(s) are supported, see [tagAppendAttributes()]. +# +# @returns The original tag object (`x`) with additional attributes (and a +# [htmlDependency()]). +# +# @export +# @examples +# +# tagz <- div( +# id = "outer", +# style = css( +# height = "600px", +# border = "3px red solid" +# ), +# div( +# id = "inner", +# style = css( +# height = "400px", +# border = "3px blue solid" +# ) +# ) +# ) +# +# # Inner doesn't fill outer +# if (interactive()) browsable(tagz) +# +# tagz <- bindFillRole(tagz, container = TRUE) +# tagz <- bindFillRole(tagz, item = TRUE, .cssSelector = "#inner") +# +# # Inner does fill outer +# if (interactive()) browsable(tagz) +# +def add_role( + tag: TagT, *, condition: bool | None, class_: str, overwrite: bool = False +) -> TagT: + if condition is None: + return tag + + # Remove the class if it already exists and we're going to add it, + # or if we're requiring it to be removed + if (condition and tag.has_class(class_)) or overwrite: + tag = tag_remove_class(tag, class_) + + if condition: + tag = tag_prepend_class(tag, class_) + return tag + + def bind_fill_role( - tag: Tag, + tag: TagT, *, - # TODO: change `item` and `container` to `fill` and `fillable` respectively item: Optional[bool] = None, container: Optional[bool] = None, -) -> Tag: - if item is not None: - if item: - tag.add_class("html-fill-item") - else: - # TODO: this remove_class method doesn't exist, but that's what we want - # tag.remove_class("html-fill-item") - ... - - if container is not None: - if container: - tag.add_class("html-fill-container") - tag.append(fill_dependencies()) - else: - # TODO: this remove_class method doesn't exist, but that's what we want - # tag.remove_class("html-fill-container") - ... + overwrite: bool = False, +) -> TagT: + tag = add_role( + tag, + condition=item, + class_=fill_item_class, + overwrite=overwrite, + ) + tag = add_role( + tag, + condition=container, + class_=fill_container_class, + overwrite=overwrite, + ) + return tag + + +########################################### + + +# Test and/or coerce fill behavior +# +# @description Filling layouts in bslib are built on the foundation of fillable +# containers and fill items (fill carriers are both fillable and +# fill). This is why most bslib components (e.g., [card()], [card_body()], +# [layout_sidebar()]) possess both `fillable` and `fill` arguments (to control +# their fill behavior). However, sometimes it's useful to add, remove, and/or +# test fillable/fill properties on arbitrary [htmltools::tag()], which these +# functions are designed to do. +# +# @references +# +# @details Although `as_fill()`, `as_fillable()`, and `as_fill_carrier()` +# can work with non-tag objects that have a [as.tags] method (e.g., htmlwidgets), +# they return the "tagified" version of that object +# +# @return +# * For `as_fill()`, `as_fillable()`, and `as_fill_carrier()`: the _tagified_ +# version `x`, with relevant tags modified to possess the relevant fill +# properties. +# * For `is_fill()`, `is_fillable()`, and `is_fill_carrier()`: a logical vector, +# with length matching the number of top-level tags that possess the relevant +# fill properties. +# +# @param x a [htmltools::tag()]. +# @param ... currently ignored. +# @param min_height,max_height Any valid [CSS unit][htmltools::validateCssUnit] +# (e.g., `150`). +# @param gap Any valid [CSS unit][htmltools::validateCssUnit]. +# @param class A character vector of class names to add to the tag. +# @param style A character vector of CSS properties to add to the tag. +# @param css_selector A character string containing a CSS selector for +# targeting particular (inner) tag(s) of interest. For more details on what +# selector(s) are supported, see [tagAppendAttributes()]. +# @export +def as_fill_carrier( + tag: TagT, + *, + min_height: Optional[CssUnit] = None, + max_height: Optional[CssUnit] = None, + gap: Optional[CssUnit] = None, + class_: Optional[str] = None, + style: Optional[str] = None, + # css_selector: Optional[str], +) -> TagT: + tag = _add_class_and_styles( + tag, + class_=class_, + style=style, + min_height=min_height, + max_height=max_height, + gap=gap, + ) + return bind_fill_role( + tag, + item=True, + container=True, + # css_selector=css_selector, + ) + + +# @rdname as_fill_carrier +# @export +def as_fillable_container( + tag: TagT, + *, + min_height: Optional[CssUnit] = None, + max_height: Optional[CssUnit] = None, + gap: Optional[CssUnit] = None, + class_: Optional[str] = None, + style: Optional[str] = None, + # css_selector: Optional[str] = None, +) -> TagT: + tag = _add_class_and_styles( + tag, + class_=class_, + style=style, + min_height=validate_css_unit(min_height), + max_height=validate_css_unit(max_height), + gap=validate_css_unit(gap), + ) + return bind_fill_role( + tag, + container=True, + # css_selector=css_selector, + ) + + +# @rdname as_fill_carrier +# @export +def as_fill_item( + tag: TagT, + *, + min_height: Optional[CssUnit] = None, + max_height: Optional[CssUnit] = None, + class_: Optional[str] = None, + style: Optional[str] = None, + # css_selector: Optional[str] = None, +) -> TagT: + tag = _add_class_and_styles( + tag, + class_=class_, + style=style, + min_height=validate_css_unit(min_height), + max_height=validate_css_unit(max_height), + ) + return bind_fill_role( + tag, + item=True, + # css_selector=css_selector, + ) + +# @rdname as_fill_carrier +# @export +def remove_all_fill(tag: TagT) -> TagT: + return bind_fill_role( + tag, + item=False, + container=False, + overwrite=True, + ) + + +# @rdname as_fill_carrier +# @export +def is_fill_carrier(x: Tag) -> bool: + return is_fillable_container(x) and is_fill_item(x) + + +# @rdname as_fill_carrier +# @export +def is_fillable_container(x: TagChild | FillingLayout) -> bool: + # TODO-future; Handle widgets + # # won't actually work until (htmltools#334) gets fixed + # renders_to_tag_class(x, fill_container_class, ".html-widget") + + return is_fill_layout(x, layout="fillable") + + +def is_fill_item(x: TagChild | FillingLayout) -> bool: + # TODO-future; Handle widgets + # # won't actually work until (htmltools#334) gets fixed + # renders_to_tag_class(x, fill_item_class, ".html-widget") + + return is_fill_layout(x, layout="fill") + + +def is_fill_layout( + x: TagChild | FillingLayout, + layout: Literal["fill", "fillable"], + recurse: bool = True, +) -> bool: + if not isinstance(x, (Tag, Tagifiable, FillingLayout)): + return False + + # x: Tag | FillingLayout | Tagifiable + + if layout == "fill": + if isinstance(x, Tag): + return x.has_class(fill_item_class) + if isinstance(x, FillingLayout): + return x.is_fill_item() + + elif layout == "fillable": + if isinstance(x, Tag): + return x.has_class(fill_container_class) + if isinstance(x, FillingLayout): + return x.is_fillable_container() + + # x: Tagifiable and not (Tag or FillingLayout) + raise TypeError( + f"`is_fill_layout(x=)` must be a `Tag` or implement the `FillingLayout` protocol methods TODO-barret expand on method names. Received object of type: `{type(x).__name__}`" + ) + + +@runtime_checkable +class FillingLayout(Protocol): + def is_fill_item(self) -> bool: + raise NotImplementedError() + + def is_fillable_container(self) -> bool: + raise NotImplementedError() + + +def _add_class_and_styles( + tag: TagT, + *, + class_: Optional[str] = None, + style: Optional[str] = None, + # css_selector: Optional[str] = None, + **kwargs: Optional[CssUnit], +) -> TagT: + if style or (len(kwargs) > 0): + style_items: dict[str, CssUnit] = {} + for k, v in kwargs.items(): + if v is not None: + style_items[k] = validate_css_unit(v) + tag = tag_add_style( + tag, + style=style, + **style_items, + ) + if class_: + tag.add_class(class_) return tag diff --git a/shiny/experimental/ui/_htmldeps.py b/shiny/experimental/ui/_htmldeps.py index 1b3e482ed..fadde0eda 100644 --- a/shiny/experimental/ui/_htmldeps.py +++ b/shiny/experimental/ui/_htmldeps.py @@ -4,30 +4,33 @@ from htmltools import HTMLDependency -from shiny import __version__ as shiny_package_version +from ..._versions import bslib as bslib_version +from ..._versions import htmltools as htmltools_version -ex_www_path = PurePath(__file__).parent.parent / "www" +x_www = PurePath(__file__).parent.parent / "www" +x_components_path = x_www / "bslib" / "components" +x_fill_path = x_www / "htmltools" / "fill" -def card_full_screen_dep() -> HTMLDependency: +def card_dependency() -> HTMLDependency: return HTMLDependency( - name="bslib-card-full-screen", - version=shiny_package_version, + name="bslib-card", + version=bslib_version, source={ "package": "shiny", - "subdir": str(ex_www_path), + "subdir": str(x_components_path), }, - script={"src": "card-full-screen.js"}, + script={"src": "card.min.js"}, ) -def fill_dependencies() -> HTMLDependency: +def fill_dependency() -> HTMLDependency: return HTMLDependency( "htmltools-fill", - "0.0.0.0", + htmltools_version, source={ "package": "shiny", - "subdir": str(ex_www_path), + "subdir": str(x_fill_path), }, stylesheet={"href": "fill.css"}, ) @@ -35,11 +38,23 @@ def fill_dependencies() -> HTMLDependency: def sidebar_dependency() -> HTMLDependency: return HTMLDependency( - "bslib-sidebar-x", - "0.0.0", + "bslib-sidebar", + bslib_version, source={ "package": "shiny", - "subdir": str(ex_www_path / "sidebar"), + "subdir": str(x_components_path), }, script={"src": "sidebar.min.js"}, ) + + +def accordion_dependency() -> HTMLDependency: + return HTMLDependency( + "bslib-accordion", + version=bslib_version, + source={ + "package": "shiny", + "subdir": str(x_components_path), + }, + script={"src": "accordion.min.js"}, + ) diff --git a/shiny/experimental/ui/_layout.py b/shiny/experimental/ui/_layout.py index 58987fcd9..b586a8f08 100644 --- a/shiny/experimental/ui/_layout.py +++ b/shiny/experimental/ui/_layout.py @@ -1,13 +1,13 @@ from __future__ import annotations -# import pdb from typing import Optional -from htmltools import TagAttrValue, TagChild, css, div +from htmltools import TagAttrs, TagAttrValue, TagChild, css, div from ..._typing_extensions import Literal from ._css import CssUnit, validate_css_unit -from ._fill import bind_fill_role +from ._fill import as_fillable_container, bind_fill_role +from ._utils import consolidate_attrs, is_01_scalar # A grid-like, column-first, layout @@ -54,7 +54,7 @@ # def layout_column_wrap( width: Optional[CssUnit], - *args: TagChild, # `TagAttrs` are not allowed here + *args: TagChild | TagAttrs, fixed_width: bool = False, heights_equal: Literal["all", "row"] = "all", fill: bool = True, @@ -62,20 +62,18 @@ def layout_column_wrap( height: Optional[CssUnit] = None, height_mobile: Optional[CssUnit] = None, gap: Optional[CssUnit] = None, - class_: Optional[str] = None, # Applies after `bind_fill_role()` + class_: Optional[str] = None, **kwargs: TagAttrValue, ): - attribs = kwargs - children = args + attrs, children = consolidate_attrs(*args, class_=class_, **kwargs) colspec: str | None = None if width is not None: - width_num = float(width) - if width_num > 0.0 and width_num <= 1.0: - num_cols = 1.0 / width_num + if is_01_scalar(width) and width > 0.0: + num_cols = 1.0 / width if not num_cols.is_integer(): raise ValueError( - "Could not interpret width argument; see ?layout_column_wrap" + "Could not interpret `layout_column_wrap(width=)` argument" ) colspec = " ".join(["1fr" for _ in range(int(num_cols))]) else: @@ -89,9 +87,8 @@ def layout_column_wrap( upgraded_children: list[TagChild] = [] for child_value in children: upgraded_children.append( - bind_fill_role( + as_fillable_container( div(bind_fill_role(div(child_value), container=fillable, item=True)), - container=True, ) ) tag_style_css = { @@ -114,14 +111,8 @@ def layout_column_wrap( "class": "bslib-column-wrap", "style": css(**tag_style_css), }, + attrs, *upgraded_children, - **attribs, ) - # pdb.set_trace() - tag = bind_fill_role(tag, item=fill) - # Give the user an opportunity to override the classes added by bind_fill_role() - if class_ is not None: - tag.add_class(class_) - - return tag + return bind_fill_role(tag, item=fill) diff --git a/shiny/experimental/ui/_navs.py b/shiny/experimental/ui/_navs.py new file mode 100644 index 000000000..51a4ec368 --- /dev/null +++ b/shiny/experimental/ui/_navs.py @@ -0,0 +1,605 @@ +from __future__ import annotations + +__all__ = ( + "navset_bar", + "navset_tab_card", + "navset_pill_card", +) + +import copy +from typing import Any, Optional, Sequence, cast + +from htmltools import MetadataNode, Tag, TagChild, TagList, div, tags + +from ..._namespaces import resolve_id +from ..._typing_extensions import Literal +from ..._utils import private_random_int +from ...types import NavSetArg +from ...ui._html_dependencies import bootstrap_deps +from ._card import CardItem, card +from ._card_item import card_body, card_footer, card_header +from ._fill import as_fill_carrier +from ._sidebar import Sidebar, layout_sidebar +from ._tag import tag_add_style + + +# ----------------------------------------------------------------------------- +# Navigation items +# ----------------------------------------------------------------------------- +class Nav: + nav: Tag + content: Optional[Tag] + + def __init__(self, nav: Tag, content: Optional[Tag] = None) -> None: + self.nav = nav + # nav_control()/nav_spacer() have None as their content + self.content = content + + def resolve( + self, selected: Optional[str], context: dict[str, Any] + ) -> tuple[TagChild, TagChild]: + # Nothing to do for nav_control()/nav_spacer() + if self.content is None: + return self.nav, None + + # At least currently, in the case where both nav and content are tags + # (i.e., nav()), the nav always has a child tag...I'm not sure if + # there's a way to statically type this + nav = copy.deepcopy(self.nav) + a_tag = cast(Tag, nav.children[0]) + if context.get("is_menu", False): + a_tag.add_class("dropdown-item") + else: + a_tag.add_class("nav-link") + nav.add_class("nav-item") + + # Hyperlink the nav to the content + content = copy.copy(self.content) + if "tabsetid" in context and "index" in context: + id = f"tab-{context['tabsetid']}-{context['index']}" + content.attrs["id"] = id + a_tag.attrs["href"] = f"#{id}" + + # Mark the nav/content as active if it should be + if isinstance(selected, str) and selected == self.get_value(): + content.add_class("active") + a_tag.add_class("active") + + nav.children[0] = a_tag + + return nav, content + + def get_value(self) -> Optional[str]: + if self.content is None: + return None + a_tag = cast(Tag, self.nav.children[0]) + return a_tag.attrs.get("data-value", None) + + def tagify(self) -> None: + raise NotImplementedError( + "nav() items must appear within navset_*() container." + ) + + +class NavSet: + args: tuple[NavSetArg | MetadataNode] + ul_class: str + id: Optional[str] + selected: Optional[str] + header: TagChild + footer: TagChild + + def __init__( + self, + *args: NavSetArg | MetadataNode, + ul_class: str, + id: Optional[str], + selected: Optional[str], + header: TagChild = None, + footer: TagChild = None, + ) -> None: + self.args = args + self.ul_class = ul_class + self.id = id + self.selected = selected + self.header = header + self.footer = footer + + def tagify(self) -> TagList | Tag: + id = self.id + ul_class = self.ul_class + if id is not None: + ul_class += " shiny-tab-input" + + nav, content = render_navset( + *self.args, ul_class=ul_class, id=id, selected=self.selected, context={} + ) + return self.layout(nav, content) + + # Types must match output of `render_navset() -> Tuple[Tag, Tag]` + def layout(self, nav: Tag, content: Tag) -> TagList | Tag: + return TagList(nav, self.header, content, self.footer) + + +# ----------------------------------------------------------------------------- +# Navigation containers +# ----------------------------------------------------------------------------- + + +class NavSetCard(NavSet): + placement: Literal["above", "below"] + sidebar: Optional[Sidebar] + + def __init__( + self, + *args: NavSetArg, + ul_class: str, + id: Optional[str], + selected: Optional[str], + sidebar: Optional[Sidebar] = None, + header: TagChild = None, + footer: TagChild = None, + placement: Literal["above", "below"] = "above", + ) -> None: + super().__init__( + *args, + ul_class=ul_class, + id=id, + selected=selected, + header=header, + footer=footer, + ) + self.sidebar = sidebar + self.placement = placement + + def layout(self, nav: Tag, content: Tag) -> Tag: + # navs = [child for child in content.children if isinstance(child, Nav)] + # not_navs = [child for child in content.children if child not in navs] + content_val: Tag | CardItem = content + + if self.sidebar: + content_val = navset_card_body(content, sidebar=self.sidebar) + + if self.placement == "below": + # TODO-barret; have carson double check this change + return card( + card_header(self.header) if self.header else None, + content_val, + card_body(self.footer, fillable=False, fill=False) + if self.footer + else None, + card_footer(nav), + ) + else: + # TODO-barret; have carson double check this change + return card( + card_header(nav), + card_body(self.header, fill=False, fillable=False) + if self.header + else None, + content_val, + card_footer(self.footer) if self.footer else None, + ) + + +def navset_card_body(content: Tag, sidebar: Optional[Sidebar] = None) -> CardItem: + content = make_tabs_fillable(content, fillable=True) + if sidebar: + content = layout_sidebar(sidebar, content, fillable=True, border=False) + return CardItem(content) + + +def navset_tab_card( + *args: NavSetArg, + id: Optional[str] = None, + selected: Optional[str] = None, + sidebar: Optional[Sidebar] = None, + header: TagChild = None, + footer: TagChild = None, +) -> NavSetCard: + """ + Render nav items as a tabset inside a card container. + + Parameters + ---------- + *args + A collection of nav items (e.g., :func:`shiny.ui.nav`). + id + If provided, will create an input value that holds the currently selected nav + item. + selected + Choose a particular nav item to select by default value (should match it's + ``value``). + header + UI to display above the selected content. + footer + UI to display below the selected content. + + See Also + ------- + ~shiny.ui.nav + ~shiny.ui.nav_menu + ~shiny.ui.nav_control + ~shiny.ui.nav_spacer + ~shiny.ui.navset_bar + ~shiny.ui.navset_tab + ~shiny.ui.navset_pill + ~shiny.ui.navset_pill_card + ~shiny.ui.navset_hidden + + Example + ------- + See :func:`~shiny.ui.nav` + """ + + return NavSetCard( + *args, + ul_class="nav nav-tabs card-header-tabs", + id=resolve_id(id) if id else None, + selected=selected, + sidebar=sidebar, + header=header, + footer=footer, + placement="above", + ) + + +def navset_pill_card( + *args: NavSetArg, + id: Optional[str] = None, + selected: Optional[str] = None, + sidebar: Optional[Sidebar] = None, + header: TagChild = None, + footer: TagChild = None, + placement: Literal["above", "below"] = "above", +) -> NavSetCard: + """ + Render nav items as a pillset inside a card container. + + Parameters + ---------- + *args + A collection of nav items (e.g., :func:`shiny.ui.nav`). + id + If provided, will create an input value that holds the currently selected nav + item. + selected + Choose a particular nav item to select by default value (should match it's + ``value``). + header + UI to display above the selected content. + footer + UI to display below the selected content. + placement + Placement of the nav items relative to the content. + + See Also + ------- + ~shiny.ui.nav + ~shiny.ui.nav_menu + ~shiny.ui.nav_control + ~shiny.ui.nav_spacer + ~shiny.ui.navset_bar + ~shiny.ui.navset_tab + ~shiny.ui.navset_pill + ~shiny.ui.navset_tab_card + ~shiny.ui.navset_hidden + + Example + ------- + See :func:`~shiny.ui.nav` + """ + + return NavSetCard( + *args, + ul_class="nav nav-pills card-header-pills", + id=resolve_id(id) if id else None, + selected=selected, + sidebar=sidebar, + header=header, + footer=footer, + placement=placement, + ) + + +class NavSetBar(NavSet): + title: TagChild + sidebar: Optional[Sidebar] + fillable: bool | list[str] + position: Literal["static-top", "fixed-top", "fixed-bottom", "sticky-top"] + bg: Optional[str] + inverse: bool + collapsible: bool + fluid: bool + + def __init__( + self, + *args: NavSetArg | MetadataNode, + ul_class: str, + title: TagChild, + id: Optional[str], + selected: Optional[str], + sidebar: Optional[Sidebar] = None, + fillable: bool | list[str] = False, + position: Literal[ + "static-top", "fixed-top", "fixed-bottom", "sticky-top" + ] = "static-top", + header: TagChild = None, + footer: TagChild = None, + bg: Optional[str] = None, + # TODO-bslib: default to 'auto', like we have in R (parse color via webcolors?) + inverse: bool = False, + collapsible: bool = True, + fluid: bool = True, + ) -> None: + super().__init__( + *args, + ul_class=ul_class, + id=id, + selected=selected, + header=header, + footer=footer, + ) + self.title = title + self.sidebar = sidebar + self.fillable = fillable + self.position = position + self.bg = bg + self.inverse = inverse + self.collapsible = collapsible + self.fluid = fluid + + def layout(self, nav: Tag, content: Tag) -> TagList: + nav_container = div( + {"class": "container-fluid" if self.fluid else "container"}, + tags.a({"class": "navbar-brand", "href": "#"}, self.title), + ) + if self.collapsible: + collapse_id = "navbar-collapse-" + private_random_int(1000, 10000) + nav_container.append( + tags.button( + tags.span(class_="navbar-toggler-icon"), + class_="navbar-toggler", + type="button", + data_bs_toggle="collapse", + data_bs_target="#" + collapse_id, + aria_controls=collapse_id, + aria_expanded="false", + aria_label="Toggle navigation", + ) + ) + nav = div(nav, id=collapse_id, class_="collapse navbar-collapse") + + nav_container.append(nav) + nav_final = tags.nav({"class": "navbar navbar-expand-md"}, nav_container) + + if self.position != "static-top": + nav_final.add_class(self.position) + + nav_final.add_class(f"navbar-{'dark' if self.inverse else 'light'}") + + if self.bg: + nav_final.attrs["style"] = "background-color: " + self.bg + else: + nav_final.add_class(f"bg-{'dark' if self.inverse else 'light'}") + + content = make_tabs_fillable(content, self.fillable, navbar=True) + + # 2023-05-11; Do not wrap `row()` around `self.header` and `self.footer` + contents: list[TagChild] = [ + child for child in [self.header, content, self.footer] if child is not None + ] + + if self.sidebar is None: + content_div = div( + *contents, class_="container-fluid" if self.fluid else "container" + ) + # If fillable is truthy, the .container also needs to be fillable + if self.fillable: + content_div = as_fill_carrier(content_div) + else: + content_div = div( + layout_sidebar( + self.sidebar, + contents, + fillable=self.fillable is not False, + border_radius=False, + border=not self.fluid, + ), + # In the fluid case, the sidebar layout should be flush (i.e., + # the .container-fluid class adds padding that we don't want) + {"class": "container"} if not self.fluid else None, + ) + + # Always have the sidebar layout fill its parent (in this case + # fillable controls whether the _main_ content portion is fillable) + content_div = as_fill_carrier(content_div) + + return TagList(nav_final, content_div) + + +# Given a .tab-content container, mark each relevant .tab-pane as a fill container/item. +def make_tabs_fillable( + content: Tag, fillable: bool | list[str] = False, navbar: bool = False +) -> Tag: + if not fillable: + return content + + # Even if only one .tab-pane wants fillable behavior, the .tab-content + # must to be a fillable container. + content = as_fill_carrier(content) + + for child in content.children: + # Only work on Tags + if not isinstance(child, Tag): + continue + # Only work on .tab-pane children + if not child.has_class("tab-pane"): + continue + # If `fillable` is a list, only fill the .tab-pane if its data-value is contained in `fillable` + if isinstance(fillable, list): + child_attr = child.attrs.get("data-value") + if child_attr is None or child_attr not in fillable: + continue + if navbar: + child = tag_add_style(child, "--bslib-navbar-margin=0;") + child = as_fill_carrier(child) + + return content + + +def navset_bar( + *args: NavSetArg | MetadataNode | Sequence[MetadataNode], + title: TagChild, + id: Optional[str] = None, + selected: Optional[str] = None, + sidebar: Optional[Sidebar] = None, + fillable: bool | list[str] = False, + position: Literal[ + "static-top", "fixed-top", "fixed-bottom", "sticky-top" + ] = "static-top", + header: TagChild = None, + footer: TagChild = None, + bg: Optional[str] = None, + # TODO-bslib: default to 'auto', like we have in R (parse color via webcolors?) + inverse: bool = False, + collapsible: bool = True, + fluid: bool = True, +) -> NavSetBar: + """ + Render nav items as a navbar. + + Parameters + ---------- + args + A collection of nav items (e.g., :func:`shiny.ui.nav`). + title + Title to display in the navbar. + id + If provided, will create an input value that holds the currently selected nav + item. + selected + Choose a particular nav item to select by default value (should match it's + ``value``). + position + Determines whether the navbar should be displayed at the top of the page with + normal scrolling behavior ("static-top"), pinned at the top ("fixed-top"), or + pinned at the bottom ("fixed-bottom"). Note that using "fixed-top" or + "fixed-bottom" will cause the navbar to overlay your body content, unless you + add padding (e.g., ``tags.style("body {padding-top: 70px;}")``). + header + UI to display above the selected content. + footer + UI to display below the selected content. + bg + Background color of the navbar (a CSS color). + inverse + Either ``True`` for a light text color or ``False`` for a dark text color. + collapsible + ``True`` to automatically collapse the navigation elements into a menu when the + width of the browser is less than 940 pixels (useful for viewing on smaller + touchscreen device) + fluid + ``True`` to use fluid layout; ``False`` to use fixed layout. + + See Also + ------- + ~shiny.ui.page_navbar + ~shiny.ui.nav + ~shiny.ui.nav_menu + ~shiny.ui.nav_control + ~shiny.ui.nav_spacer + ~shiny.ui.navset_tab + ~shiny.ui.navset_pill + ~shiny.ui.navset_tab_card + ~shiny.ui.navset_pill_card + ~shiny.ui.navset_hidden + + Example + ------- + See :func:`~shiny.ui.nav`. + """ + + # If args contains any lists, flatten them into args. + new_args: Sequence[NavSetArg | MetadataNode] = [] + for arg in args: + if isinstance(arg, (list, tuple)): + new_args.extend(arg) + else: + new_args.append(cast(NavSetArg, arg)) + + return NavSetBar( + *new_args, + ul_class="nav navbar-nav", + id=resolve_id(id) if id else None, + selected=selected, + sidebar=sidebar, + fillable=fillable, + title=title, + position=position, + header=header, + footer=footer, + bg=bg, + inverse=inverse, + collapsible=collapsible, + fluid=fluid, + ) + + +# ----------------------------------------------------------------------------- +# Utilities for rendering navs +# -----------------------------------------------------------------------------\ +def render_navset( + *items: NavSetArg | MetadataNode, + ul_class: str, + id: Optional[str], + selected: Optional[str], + context: dict[str, Any], +) -> tuple[Tag, Tag]: + tabsetid = private_random_int(1000, 10000) + + # Separate MetadataNodes from NavSetArgs. + metadata_args = [x for x in items if isinstance(x, MetadataNode)] + navset_args = [x for x in items if not isinstance(x, MetadataNode)] + + # If the user hasn't provided a selected value, use the first one + if selected is None: + for x in navset_args: + selected = x.get_value() + if selected is not None: + break + + ul_tag = tags.ul( + bootstrap_deps(), + metadata_args, + class_=ul_class, + id=id, + data_tabsetid=tabsetid, + ) + div_tag = div(class_="tab-content", data_tabsetid=tabsetid) + for i, x in enumerate(navset_args): + nav, contents = x.resolve( + selected, {**context, "tabsetid": tabsetid, "index": i} + ) + ul_tag.append(nav) + div_tag.append(contents) + + return ul_tag, div_tag + + +# # Card definition was gutted for bslib version. +# # * Bootstrap deps are not added + +# def card(*args: TagChild, header: TagChild = None, footer: TagChild = None) -> Tag: +# if header: +# header = div(header, class_="card-header") +# if footer: +# footer = div(footer, class_="card-footer") + +# return div( +# header, +# div(*args, class_="card-body"), +# footer, +# bootstrap_deps(), +# class_="card", +# ) diff --git a/shiny/experimental/ui/_page.py b/shiny/experimental/ui/_page.py index 0f08fbd70..96db631a8 100644 --- a/shiny/experimental/ui/_page.py +++ b/shiny/experimental/ui/_page.py @@ -1,36 +1,202 @@ from __future__ import annotations -from typing import Optional +from typing import Optional, Sequence, overload -from htmltools import TagAttrs, TagChild, css, tags - -from shiny import ui +from htmltools import ( + MetadataNode, + Tag, + TagAttrs, + TagAttrValue, + TagChild, + TagList, + css, + tags, +) +from ..._typing_extensions import Literal +from ...types import MISSING, MISSING_TYPE, NavSetArg +from ...ui import page_bootstrap +from ...ui._utils import get_window_title from ._css import CssUnit, validate_css_unit -from ._fill import bind_fill_role +from ._fill import as_fillable_container +from ._navs import navset_bar +from ._sidebar import Sidebar +from ._utils import consolidate_attrs + + +def page_navbar( + *args: NavSetArg | MetadataNode | Sequence[MetadataNode], + title: Optional[str | Tag | TagList] = None, + id: Optional[str] = None, + selected: Optional[str] = None, + sidebar: Optional[Sidebar] = None, + # Only page_navbar gets enhancedtreatement for `fillable` + # If an `*args`'s `data-value` attr string is in `fillable`, then the component is fillable + fillable: bool | list[str] = False, + fill_mobile: bool = False, + position: Literal["static-top", "fixed-top", "fixed-bottom"] = "static-top", + header: Optional[TagChild] = None, + footer: Optional[TagChild] = None, + bg: Optional[str] = None, + inverse: bool = False, + collapsible: bool = True, + fluid: bool = True, + window_title: str | MISSING_TYPE = MISSING, + lang: Optional[str] = None, +) -> Tag: + """ + Create a page with a navbar and a title. + + Parameters + ---------- + + args + UI elements. + title + The browser window title (defaults to the host URL of the page). Can also be set + as a side effect via :func:`~shiny.ui.panel_title`. + id + If provided, will create an input value that holds the currently selected nav + item. + selected + Choose a particular nav item to select by default value (should match it's + ``value``). + position + Determines whether the navbar should be displayed at the top of the page with + normal scrolling behavior ("static-top"), pinned at the top ("fixed-top"), or + pinned at the bottom ("fixed-bottom"). Note that using "fixed-top" or + "fixed-bottom" will cause the navbar to overlay your body content, unless you + add padding (e.g., ``tags.style("body {padding-top: 70px;}")``). + header + UI to display above the selected content. + footer + UI to display below the selected content. + bg + Background color of the navbar (a CSS color). + inverse + Either ``True`` for a light text color or ``False`` for a dark text color. + collapsible + ``True`` to automatically collapse the navigation elements into a menu when the + width of the browser is less than 940 pixels (useful for viewing on smaller + touchscreen device) + fluid + ``True`` to use fluid layout; ``False`` to use fixed layout. + window_title + The browser's window title (defaults to the host URL of the page). Can also be + set as a side effect via :func:`~shiny.ui.panel_title`. + lang + ISO 639-1 language code for the HTML page, such as ``"en"`` or ``"ko"``. This + will be used as the lang in the ```` tag, as in ````. The + default, `None`, results in an empty string. + + Returns + ------- + : + A UI element. + + See Also + ------- + :func:`~shiny.ui.nav` + :func:`~shiny.ui.nav_menu` + :func:`~shiny.ui.navset_bar` + :func:`~shiny.ui.page_fluid` + + Example + ------- + See :func:`~shiny.ui.nav`. + """ + if sidebar is not None and not isinstance(sidebar, Sidebar): + raise TypeError( + "`sidebar=` is not a `Sidebar` instance. Use `ui.sidebar(...)` to create one." + ) + + # If a sidebar is provided, we want the layout_sidebar(fill = TRUE) component + # (which is a sibling of the