diff --git a/CHANGELOG.md b/CHANGELOG.md index 0299552e1..3c9f43adb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### New features * `shiny run` now takes a `--reload-dir ` argument that indicates a directory `--reload` should (recursively) monitor for changes, in addition to the app's parent directory. Can be used more than once. (#353) +* The default theme has been updated to use Bootstrap 5 with custom Shiny style enhancements. (#624) +* Added experimental UI `tooltip()`, `update_tooltip()`, and `toggle_tooltip()` for easy creation (and server-side updating) of [Bootstrap tooltips](https://getbootstrap.com/docs/5.2/components/tooltips/) (a way to display additional information when focusing (or hovering over) a UI element). (#629) + ### Bug fixes diff --git a/MANIFEST.in b/MANIFEST.in index a5d757e91..3bedfd7ec 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -8,5 +8,5 @@ recursive-include docs *.rst conf.py Makefile make.bat *.jpg *.png *.gif recursive-include shiny/www * recursive-include shiny/experimental/www * -recursive-include shiny/examples * +recursive-include shiny/api-examples * recursive-include shiny/ui/dataframe/js/dist * diff --git a/e2e/README.md b/e2e/README.md index bb3a34d57..18c936a89 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -10,7 +10,7 @@ tests against their apps.) The actual tests are in subdirectories. Each subdirectory contains one or more Pytest files (`test_*.py`) containing [Playwright](https://playwright.dev/python/) assertions, and optionally, a single app (`app.py`) that the assertions test against. (The app is -optional, because the tests may also be for apps in the `../examples` or `../shiny/examples` directory.) +optional, because the tests may also be for apps in the `../examples` or `../shiny/api-examples` directory.) ## Running tests diff --git a/e2e/conftest.py b/e2e/conftest.py index f8f99e1cd..4c03b863f 100644 --- a/e2e/conftest.py +++ b/e2e/conftest.py @@ -194,9 +194,16 @@ def create_example_fixture(example_name: str, scope: str = "module"): def create_doc_example_fixture(example_name: str, scope: str = "module"): + """Used to create app fixtures from apps in py-shiny/shiny/api-examples""" + return create_app_fixture( + here / "../shiny/api-examples" / example_name / "app.py", scope + ) + + +def x_create_doc_example_fixture(example_name: str, scope: str = "module"): """Used to create app fixtures from apps in py-shiny/shiny/examples""" return create_app_fixture( - here / "../shiny/examples" / example_name / "app.py", scope + here / "../shiny/experimental/api-examples" / example_name / "app.py", scope ) diff --git a/e2e/controls.py b/e2e/controls.py index 8eb1e78c9..9fa3d6762 100644 --- a/e2e/controls.py +++ b/e2e/controls.py @@ -938,7 +938,7 @@ def expect_locator_values_in_list( *, page: Page, loc_container: Locator, - el_type: str, + el_type: Locator | str, arr_name: str, arr: ListPatternOrStr, is_checked: bool | MISSING_TYPE = MISSING, @@ -949,13 +949,20 @@ def expect_locator_values_in_list( # Make sure the locator has len(uniq_arr) input elements _MultipleDomItems.assert_arr_is_unique(arr, f"`{arr_name}` must be unique") - is_checked_str = _MultipleDomItems.checked_css_str(is_checked) - item_selector = f"{el_type}{is_checked_str}" + if isinstance(el_type, Locator): + if not isinstance(is_checked, MISSING_TYPE): + raise RuntimeError( + "`is_checked` cannot be specified if `el_type` is a Locator" + ) + loc_item = el_type + else: + is_checked_str = _MultipleDomItems.checked_css_str(is_checked) + loc_item = page.locator(f"{el_type}{is_checked_str}") # If there are no items, then we should not have any elements if len(arr) == 0: - playwright_expect(loc_container.locator(item_selector)).to_have_count( + playwright_expect(loc_container.locator(el_type)).to_have_count( 0, timeout=timeout ) return @@ -964,7 +971,7 @@ def expect_locator_values_in_list( # Find all items in set for item, i in zip(arr, range(len(arr))): # Get all elements of type - has_locator = page.locator(item_selector) + has_locator = loc_item # Get the `n`th matching element has_locator = has_locator.nth(i) # Make sure that element has the correct attribute value @@ -982,7 +989,7 @@ def expect_locator_values_in_list( # Make sure other items are not in set # If we know all elements are contained in the container, # and all elements all unique, then it should have a count of `len(arr)` - loc_inputs = loc_container.locator(item_selector) + loc_inputs = loc_container.locator(loc_item) try: playwright_expect(loc_inputs).to_have_count(len(arr), timeout=timeout) except AssertionError as e: @@ -992,14 +999,14 @@ def expect_locator_values_in_list( playwright_expect(loc_container_orig).to_have_count(1, timeout=timeout) # Expecting the container to contain {len(arr)} items - playwright_expect(loc_container_orig.locator(item_selector)).to_have_count( + playwright_expect(loc_container_orig.locator(loc_item)).to_have_count( len(arr), timeout=timeout ) for item, i in zip(arr, range(len(arr))): # Expecting item `{i}` to be `{item}` playwright_expect( - loc_container_orig.locator(item_selector).nth(i) + loc_container_orig.locator(loc_item).nth(i) ).to_have_attribute(key, item, timeout=timeout) # Could not find the reason why. Raising the original error. @@ -1374,10 +1381,14 @@ def __init__( def expect_tick_labels( self, - value: ListPatternOrStr, + value: ListPatternOrStr | None, *, timeout: Timeout = None, ) -> None: + if value is None: + playwright_expect(self.loc_irs_ticks).to_have_count(0) + return + playwright_expect(self.loc_irs_ticks).to_have_text(value, timeout=timeout) def expect_animate(self, exists: bool, *, timeout: Timeout = None) -> None: @@ -1553,10 +1564,10 @@ def slow_move(x: float, y: float, delay: float = sleep_time) -> None: ) def _grid_bb(self, *, timeout: Timeout = None) -> FloatRect: - grid = self.loc_container.locator(".irs-grid") + grid = self.loc_irs.locator("> .irs > .irs-line") grid_bb = grid.bounding_box(timeout=timeout) if grid_bb is None: - raise RuntimeError("Couldn't find bounding box for .irs-grid") + raise RuntimeError("Couldn't find bounding box for .irs-line") return grid_bb def _handle_center( @@ -2092,6 +2103,7 @@ def expect_value( *, timeout: Timeout = None, ) -> None: + """Note this function will trim value and output text value before comparing them""" self.expect.to_have_text(value, timeout=timeout) @@ -2332,3 +2344,354 @@ def expect_n_row( n, timeout=timeout, ) + + +class Sidebar( + _WidthLocM, + _InputWithContainer, +): + # *args: TagChild | TagAttrs, + # width: CssUnit = 250, + # position: Literal["left", "right"] = "left", + # open: Literal["desktop", "open", "closed", "always"] = "desktop", + # id: Optional[str] = None, + # title: TagChild | str = None, + # bg: Optional[str] = None, + # fg: Optional[str] = None, + # class_: Optional[str] = None, # TODO-future; Consider using `**kwargs` instead + # max_height_mobile: Optional[str | float] = None, + def __init__(self, page: Page, id: str) -> None: + super().__init__( + page, + id=id, + loc=f"> div#{id}", + loc_container="div.bslib-sidebar-layout", + ) + self.loc_handle = self.loc_container.locator("button.collapse-toggle") + + def expect_title(self, value: PatternOrStr, *, timeout: Timeout = None) -> None: + playwright_expect(self.loc).to_have_text(value, timeout=timeout) + + def expect_handle(self, exists: bool, *, timeout: Timeout = None) -> None: + playwright_expect(self.loc_handle).to_have_count(int(exists), timeout=timeout) + + def expect_open(self, open: bool, *, timeout: Timeout = None) -> None: + playwright_expect(self.loc_handle).to_have_attribute( + "aria-expanded", str(open).lower(), timeout=timeout + ) + + +class _CardBodyP(_InputBaseP, Protocol): + loc_body: Locator + + +class _CardBodyM: + def expect_body( + self: _CardBodyP, + text: PatternOrStr | list[PatternOrStr], + *, + timeout: Timeout = None, + ) -> None: + """Note: If testing against multiple elements, text should be an array""" + playwright_expect(self.loc).to_have_text( + text, + timeout=timeout, + ) + + +class _CardFooterLayoutP(_InputBaseP, Protocol): + loc_footer: Locator + + +class _CardFooterM: + def expect_footer( + self: _CardFooterLayoutP, + text: PatternOrStr, + *, + timeout: Timeout = None, + ) -> None: + playwright_expect(self.loc_footer).to_have_text( + text, + timeout=timeout, + ) + + +class _CardFullScreenLayoutP(_OutputBaseP, Protocol): + loc_title: Locator + _loc_fullscreen: Locator + _loc_close_button: Locator + + +class _CardFullScreenM: + def open_full_screen( + self: _CardFullScreenLayoutP, *, timeout: Timeout = None + ) -> None: + self.loc_title.hover(timeout=timeout) + self._loc_fullscreen.wait_for(state="visible", timeout=timeout) + self._loc_fullscreen.click(timeout=timeout) + + def close_full_screen( + self: _CardFullScreenLayoutP, *, timeout: Timeout = None + ) -> None: + self._loc_close_button.click(timeout=timeout) + + def expect_full_screen( + self: _CardFullScreenLayoutP, open: bool, *, timeout: Timeout = None + ) -> None: + playwright_expect(self._loc_close_button).to_have_count( + int(open), timeout=timeout + ) + + +class ValueBox( + _WidthLocM, + _CardBodyM, + _CardFullScreenM, + _InputWithContainer, +): + # title: TagChild, + # value: TagChild, + # *args: TagChild | TagAttrs, + # showcase: TagChild = None, + # showcase_layout: ((TagChild, Tag) -> CardItem) | None = None, + # full_screen: bool = False, + # theme_color: str | None = "primary", + # height: CssUnit | None = None, + # max_height: CssUnit | None = None, + # fill: bool = True, + # class_: str | None = None, + # **kwargs: TagAttrValue + def __init__(self, page: Page, id: str) -> None: + super().__init__( + page, + id=id, + loc_container=f"div#{id}.bslib-value-box", + loc="> div > .value-box-grid", + ) + value_box_grid = self.loc + self.loc = value_box_grid.locator( + "> div > .value-box-area > :not(:first-child)" + ) + self.loc_showcase = value_box_grid.locator("> div > .value-box-showcase") + self.loc_title = value_box_grid.locator( + "> div > .value-box-area > :first-child" + ) + self.loc_body = self.loc + self._loc_fullscreen = self.loc_container.locator( + "> bslib-tooltip > .bslib-full-screen-enter" + ) + + # an easier approach is using `#bslib-full-screen-overlay:has(+ div#{id}.card) > a` + # but playwright doesn't allow that + self._loc_close_button = ( + self.page.locator(f"#bslib-full-screen-overlay + div#{id}.bslib-value-box") + .locator("..") + .locator("#bslib-full-screen-overlay > a") + ) + + def expect_height(self, value: StyleValue, *, timeout: Timeout = None) -> None: + expect_to_have_style( + self.loc_container, "--bslib-grid-height", value, timeout=timeout + ) + + def expect_title( + self, + text: PatternOrStr, + *, + timeout: Timeout = None, + ) -> None: + playwright_expect(self.loc_title).to_have_text( + text, + timeout=timeout, + ) + + # hard to test since it can be customized by user + # def expect_showcase_layout(self, layout, *, timeout: Timeout = None) -> None: + # raise NotImplementedError() + + +class Card(_WidthLocM, _CardFooterM, _CardBodyM, _CardFullScreenM, _InputWithContainer): + # *args: TagChild | TagAttrs | CardItem, + # full_screen: bool = False, + # height: CssUnit | None = None, + # max_height: CssUnit | None = None, + # min_height: CssUnit | None = None, + # fill: bool = True, + # class_: str | None = None, + # wrapper: WrapperCallable | MISSING_TYPE | None = MISSING, + # **kwargs: TagAttrValue + def __init__(self, page: Page, id: str) -> None: + super().__init__( + page, + id=id, + loc_container=f"div#{id}.card", + loc="> div.card-body", + ) + self.loc_title = self.loc_container.locator("> div.card-header") + self.loc_footer = self.loc_container.locator("> div.card-footer") + self._loc_fullscreen = self.loc_container.locator( + "> bslib-tooltip > .bslib-full-screen-enter" + ) + # an easier approach is using `#bslib-full-screen-overlay:has(+ div#{id}.card) > a` + # but playwright doesn't allow that + self._loc_close_button = ( + self.page.locator(f"#bslib-full-screen-overlay + div#{id}") + .locator("..") + .locator("#bslib-full-screen-overlay > a") + ) + self.loc_body = self.loc + + def expect_header( + self, + text: PatternOrStr, + *, + timeout: Timeout = None, + ) -> None: + playwright_expect(self.loc_title).to_have_text( + text, + timeout=timeout, + ) + + # def expect_body( + # self, + # text: PatternOrStr, + # index: int = 0, + # *, + # timeout: Timeout = None, + # ) -> None: + # """Note: Function requires an index since multiple bodies can exist in loc""" + # playwright_expect(self.loc.nth(index).locator("> :first-child")).to_have_text( + # text, + # timeout=timeout, + # ) + + def expect_max_height(self, value: StyleValue, *, timeout: Timeout = None) -> None: + expect_to_have_style(self.loc_container, "max-height", value, timeout=timeout) + + def expect_min_height(self, value: StyleValue, *, timeout: Timeout = None) -> None: + expect_to_have_style(self.loc_container, "min-height", value, timeout=timeout) + + def expect_height(self, value: StyleValue, *, timeout: Timeout = None) -> None: + expect_to_have_style(self.loc_container, "height", value, timeout=timeout) + + +### Experimental below + + +class Accordion( + _WidthLocM, + _InputWithContainer, +): + # *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, + def __init__(self, page: Page, id: str) -> None: + super().__init__( + page, + id=id, + loc="> div.accordion-item", + loc_container=f"div#{id}.accordion.shiny-bound-input", + ) + self.loc_open = self.loc.locator( + # Return self + "xpath=.", + # Simple approach as position is not needed + has=page.locator( + "> div.accordion-collapse.show", + ), + ) + + def expect_height(self, value: StyleValue, *, timeout: Timeout = None) -> None: + expect_to_have_style(self.loc_container, "height", value, timeout=timeout) + + def expect_width(self, value: StyleValue, *, timeout: Timeout = None) -> None: + expect_to_have_style(self.loc_container, "width", value, timeout=timeout) + + def expect_open( + self, + value: list[PatternOrStr], + *, + timeout: Timeout = None, + ) -> None: + _MultipleDomItems.expect_locator_values_in_list( + page=self.page, + loc_container=self.loc_container, + el_type=self.page.locator( + "> div.accordion-item", + has=self.page.locator("> div.accordion-collapse.show"), + ), + # el_type="> div.accordion-item:has(> div.accordion-collapse.show)", + arr_name="value", + arr=value, + key="data-value", + timeout=timeout, + ) + + def expect_panels( + self, + value: list[PatternOrStr], + *, + timeout: Timeout = None, + ) -> None: + _MultipleDomItems.expect_locator_values_in_list( + page=self.page, + loc_container=self.loc_container, + el_type="> div.accordion-item", + arr_name="value", + arr=value, + key="data-value", + timeout=timeout, + ) + + def accordion_panel( + self, + data_value: str, + ) -> AccordionPanel: + return AccordionPanel(self.page, self.id, data_value) + + +class AccordionPanel( + _WidthLocM, + _InputWithContainer, +): + # self, + # *args: TagChild | TagAttrs, + # data_value: str, + # icon: TagChild | None, + # title: TagChild | None, + # id: str | None, + # **kwargs: TagAttrValue, + def __init__(self, page: Page, id: str, data_value: str) -> None: + super().__init__( + page, + id=id, + loc=f"> div.accordion-item[data-value='{data_value}']", + loc_container=f"div#{id}.accordion.shiny-bound-input", + ) + + self.loc_label = self.loc.locator( + "> .accordion-header > .accordion-button > .accordion-title" + ) + + self.loc_icon = self.loc.locator( + "> .accordion-header > .accordion-button > .accordion-icon" + ) + + self.loc_body = self.loc.locator("> .accordion-collapse") + + def expect_label(self, value: PatternOrStr, *, timeout: Timeout = None) -> None: + playwright_expect(self.loc_label).to_have_text(value, timeout=timeout) + + def expect_body(self, value: PatternOrStr, *, timeout: Timeout = None) -> None: + playwright_expect(self.loc_body).to_have_text(value, timeout=timeout) + + def expect_icon(self, value: PatternOrStr, *, timeout: Timeout = None) -> None: + playwright_expect(self.loc_icon).to_have_text(value, timeout=timeout) + + def expect_open(self, is_open: bool, *, timeout: Timeout = None) -> None: + _expect_class_value(self.loc_body, "show", is_open, timeout=timeout) diff --git a/e2e/cpuinfo/test_app.py b/e2e/cpuinfo/test_app.py index 8b02adae6..d07976b70 100644 --- a/e2e/cpuinfo/test_app.py +++ b/e2e/cpuinfo/test_app.py @@ -1,6 +1,6 @@ # pyright: reportUnknownMemberType=false -# TODO-future; Convert test into loop that tests all examples to make sure they load +# TODO-karan; Convert test into loop that tests all examples to make sure they load import re diff --git a/e2e/examples/test_examples.py b/e2e/examples/test_examples.py index 5534039d8..cd512bf0c 100644 --- a/e2e/examples/test_examples.py +++ b/e2e/examples/test_examples.py @@ -24,7 +24,7 @@ def get_apps(path: str) -> typing.List[str]: example_apps: typing.List[str] = [ *get_apps("../../examples"), - *get_apps("../../shiny/examples"), + *get_apps("../../shiny/api-examples"), ] app_idle_wait = {"duration": 300, "timeout": 5 * 1000} @@ -36,6 +36,8 @@ def get_apps(path: str) -> typing.List[str]: "SafeException": True, "global_pyplot": True, "static_plots": ["PlotnineWarning", "RuntimeWarning"], + # https://github.com/rstudio/py-shiny/issues/611#issuecomment-1632866419 + "penguins": ["UserWarning", "plt.tight_layout"], } app_allow_js_errors: typing.Dict[str, typing.List[str]] = { "brownian": ["Failed to acquire camera feed:"], diff --git a/shiny/experimental/e2e/accordion/app.py b/e2e/experimental/accordion/app.py similarity index 100% rename from shiny/experimental/e2e/accordion/app.py rename to e2e/experimental/accordion/app.py diff --git a/e2e/experimental/accordion/test_accordion.py b/e2e/experimental/accordion/test_accordion.py new file mode 100644 index 000000000..330b68544 --- /dev/null +++ b/e2e/experimental/accordion/test_accordion.py @@ -0,0 +1,78 @@ +from conftest import ShinyAppProc +from controls import Accordion, InputActionButton, OutputTextVerbatim +from playwright.sync_api import Page + + +def test_accordion(page: Page, local_app: ShinyAppProc) -> None: + page.goto(local_app.url) + + acc = Accordion(page, "acc") + acc_panel_A = acc.accordion_panel("Section A") + output_txt_verbatim = OutputTextVerbatim(page, "acc_txt") + alternate_button = InputActionButton(page, "alternate") + open_all_button = InputActionButton(page, "open_all") + close_all_button = InputActionButton(page, "close_all") + toggle_b_button = InputActionButton(page, "toggle_b") + toggle_updates_button = InputActionButton(page, "toggle_updates") + toggle_efg_button = InputActionButton(page, "toggle_efg") + acc.expect_width(None) + acc.expect_height(None) + + # initial state - by default only A is open + acc.expect_panels(["Section A", "Section B", "Section C", "Section D"]) + output_txt_verbatim.expect_value("input.acc(): ('Section A',)") + acc.expect_open(["Section A"]) + acc_panel_A.expect_label("Section A") + acc_panel_A.expect_body("Some narrative for section A") + acc_panel_A.expect_open(True) + + alternate_button.click() + acc.expect_open(["Section B", "Section D"]) + output_txt_verbatim.expect_value("input.acc(): ('Section B', 'Section D')") + + alternate_button.click() + acc.expect_open(["Section A", "Section C"]) + output_txt_verbatim.expect_value("input.acc(): ('Section A', 'Section C')") + + open_all_button.click() + acc.expect_open(["Section A", "Section B", "Section C", "Section D"]) + output_txt_verbatim.expect_value( + "input.acc(): ('Section A', 'Section B', 'Section C', 'Section D')" + ) + + close_all_button.click() + acc.expect_open([]) + output_txt_verbatim.expect_value("input.acc(): None") + + toggle_b_button.click() + acc.expect_open(["Section B"]) + output_txt_verbatim.expect_value("input.acc(): ('Section B',)") + + acc_panel_updated_A = acc.accordion_panel("updated_section_a") + toggle_updates_button.click() + acc_panel_updated_A.expect_label("Updated title") + acc_panel_updated_A.expect_body("Updated body") + acc_panel_updated_A.expect_icon("Look! An icon! -->") + + acc.expect_panels(["updated_section_a", "Section B", "Section C", "Section D"]) + output_txt_verbatim.expect_value("input.acc(): ('updated_section_a', 'Section B')") + + toggle_efg_button.click() + acc.expect_panels( + [ + "updated_section_a", + "Section B", + "Section C", + "Section D", + "Section E", + "Section F", + "Section G", + ] + ) + acc.expect_open( + ["updated_section_a", "Section B", "Section E", "Section F", "Section G"] + ) + # will be uncommented once https://github.com/rstudio/bslib/issues/565 is fixed + # output_txt_verbatim.expect_value( + # "input.acc(): ('updated_section_a', 'Section B', 'Section E', 'Section F', 'Section G')" + # ) diff --git a/e2e/experimental/card/app.py b/e2e/experimental/card/app.py new file mode 100644 index 000000000..8a6b39107 --- /dev/null +++ b/e2e/experimental/card/app.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +import shiny.experimental as x +from shiny import App, ui + +app_ui = ui.page_fluid( + x.ui.card( + x.ui.card_header("This is the header"), + x.ui.card_title("This is the title"), + ui.p("This is the body."), + x.ui.card_image( + file=None, + src="https://posit.co/wp-content/uploads/2022/10/Posit-logo-h-full-color-RGB-TM.svg", + ), + ui.p("This is still the body."), + x.ui.card_footer("This is the footer"), + full_screen=True, + id="card1", + ), +) + + +app = App(app_ui, server=None) diff --git a/e2e/experimental/card/test_card.py b/e2e/experimental/card/test_card.py new file mode 100644 index 000000000..a86bed4fa --- /dev/null +++ b/e2e/experimental/card/test_card.py @@ -0,0 +1,26 @@ +from conftest import ShinyAppProc +from controls import Card +from playwright.sync_api import Page + + +def test_card(page: Page, local_app: ShinyAppProc) -> None: + page.goto(local_app.url) + + card = Card(page, "card1") + card.expect_max_height(None) + card.expect_min_height(None) + card.expect_height(None) + card.expect_header("This is the header") + card.expect_footer("This is the footer") + card.expect_body( + [ + "\nThis is the title\nThis is the body.\n", + "\n\n", + "\nThis is still the body.\n", + ] + ) + card.expect_full_screen(False) + card.open_full_screen() + card.expect_full_screen(True) + card.close_full_screen() + card.expect_full_screen(False) diff --git a/e2e/experimental/test_autoresize.py b/e2e/experimental/test_autoresize.py new file mode 100644 index 000000000..5c08142a9 --- /dev/null +++ b/e2e/experimental/test_autoresize.py @@ -0,0 +1,34 @@ +from conftest import ShinyAppProc, x_create_doc_example_fixture +from controls import InputTextArea, OutputTextVerbatim +from playwright.sync_api import Locator, Page + +app = x_create_doc_example_fixture("input_text_area") + +resize_number = 6 + + +def get_box_height(locator: Locator) -> float: + bounding_box = locator.bounding_box() + if bounding_box is not None: + return bounding_box["height"] + else: + return 0 + + +def test_autoresize(page: Page, app: ShinyAppProc) -> None: + page.goto(app.url) + + input_area = InputTextArea(page, "caption") + output_txt_verbatim = OutputTextVerbatim(page, "value") + input_area.expect_height(None) + input_area.expect_width(None) + input_area.set("test value") + # use bounding box approach since height is dynamic + initial_height = get_box_height(input_area.loc) + output_txt_verbatim.expect_value("test value") + for _ in range(resize_number): + input_area.loc.press("Enter") + input_area.loc.type("end value") + return_txt = "\n" * resize_number + output_txt_verbatim.expect_value(f"test value{return_txt}end value") + assert get_box_height(input_area.loc) > initial_height diff --git a/e2e/experimental/test_sidebar.py b/e2e/experimental/test_sidebar.py new file mode 100644 index 000000000..b2f8f9da0 --- /dev/null +++ b/e2e/experimental/test_sidebar.py @@ -0,0 +1,45 @@ +from conftest import ShinyAppProc, x_create_doc_example_fixture +from controls import OutputTextVerbatim, Sidebar +from playwright.sync_api import Page + +app = x_create_doc_example_fixture("sidebar") + + +def test_autoresize(page: Page, app: ShinyAppProc) -> None: + page.goto(app.url) + + left_sidebar = Sidebar(page, "sidebar_left") + output_txt_left = OutputTextVerbatim(page, "state_left") + left_sidebar.expect_title("Left sidebar content") + output_txt_left.expect_value("input.sidebar_left(): True") + left_sidebar.expect_handle(True) + left_sidebar.expect_open(True) + left_sidebar.loc_handle.click() + left_sidebar.expect_open(False) + output_txt_left.expect_value("input.sidebar_left(): False") + + right_sidebar = Sidebar(page, "sidebar_right") + output_txt_right = OutputTextVerbatim(page, "state_right") + right_sidebar.expect_title("Right sidebar content") + output_txt_right.expect_value("input.sidebar_right(): True") + right_sidebar.expect_handle(True) + right_sidebar.expect_open(True) + right_sidebar.loc_handle.click() + right_sidebar.expect_open(False) + output_txt_right.expect_value("input.sidebar_right(): False") + + closed_sidebar = Sidebar(page, "sidebar_closed") + output_txt_closed = OutputTextVerbatim(page, "state_closed") + output_txt_closed.expect_value("input.sidebar_closed(): False") + closed_sidebar.expect_handle(True) + closed_sidebar.expect_open(False) + closed_sidebar.loc_handle.click() + closed_sidebar.expect_title("Closed sidebar content") + closed_sidebar.expect_open(True) + output_txt_closed.expect_value("input.sidebar_closed(): True") + + always_sidebar = Sidebar(page, "sidebar_always") + output_txt_always = OutputTextVerbatim(page, "state_always") + always_sidebar.expect_title("Always sidebar content") + output_txt_always.expect_value("input.sidebar_always(): False") + always_sidebar.expect_handle(False) diff --git a/e2e/experimental/value_box/app.py b/e2e/experimental/value_box/app.py new file mode 100644 index 000000000..2783180b3 --- /dev/null +++ b/e2e/experimental/value_box/app.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import shiny.experimental as x +from shiny import App, ui + +piggy_bank = ui.HTML( + '' +) +arrow_up = ui.HTML( + '' +) + +app_ui = ui.page_fluid( + x.ui.value_box( + "KPI Title", + ui.h1(ui.HTML("$1 Billion Dollars")), + ui.span(arrow_up, " 30% VS PREVIOUS 30 DAYS"), + showcase=piggy_bank, + class_="bg-success", + full_screen=True, + # showcase_layout=x.ui._valuebox.showcase_left_center(), + id="valuebox1", + ), + x.ui.value_box( + "KPI Title", + ui.h1(ui.HTML("$1 Billion Dollars")), + ui.span(arrow_up, " 30% VS PREVIOUS 30 DAYS"), + showcase=piggy_bank, + class_="bg-success", + full_screen=True, + showcase_layout=x.ui.showcase_top_right(width="70%"), + id="valuebox2", + ), +) + + +app = App(app_ui, server=None) diff --git a/e2e/experimental/value_box/test_valuebox.py b/e2e/experimental/value_box/test_valuebox.py new file mode 100644 index 000000000..d4e9924e7 --- /dev/null +++ b/e2e/experimental/value_box/test_valuebox.py @@ -0,0 +1,19 @@ +import pytest +from conftest import ShinyAppProc +from controls import ValueBox +from playwright.sync_api import Page + + +@pytest.mark.parametrize("value_box_id", ["valuebox1", "valuebox2"]) +def test_valuebox(page: Page, local_app: ShinyAppProc, value_box_id: str) -> None: + page.goto(local_app.url) + + value_box = ValueBox(page, value_box_id) + value_box.expect_height(None) + value_box.expect_title("KPI Title") + value_box.expect_full_screen(False) + value_box.open_full_screen() + value_box.expect_full_screen(True) + value_box.expect_body(["$1 Billion Dollars", "30% VS PREVIOUS 30 DAYS"]) + value_box.close_full_screen() + value_box.expect_full_screen(False) diff --git a/e2e/inputs/input_slider/app.py b/e2e/inputs/input_slider/app.py index 5294da152..5f3626604 100644 --- a/e2e/inputs/input_slider/app.py +++ b/e2e/inputs/input_slider/app.py @@ -51,6 +51,7 @@ def slider_row( pre="$", sep=",", animate=True, + ticks=True, ), slider_row( "Looping Animation", diff --git a/e2e/inputs/input_slider/test_input_slider_app.py b/e2e/inputs/input_slider/test_input_slider_app.py index ae6c5750c..bb109e7f1 100644 --- a/e2e/inputs/input_slider/test_input_slider_app.py +++ b/e2e/inputs/input_slider/test_input_slider_app.py @@ -17,7 +17,7 @@ def test_slider_regular(page: Page, local_app: ShinyAppProc) -> None: s0.expect_min("0") s0.expect_max("1000") s0.expect_step("1") - s0.expect_ticks("true") + s0.expect_ticks("false") s0.expect_sep(",") s0.expect_pre(None) s0.expect_post(None) @@ -27,7 +27,7 @@ def test_slider_regular(page: Page, local_app: ShinyAppProc) -> None: s0.expect_animate(exists=False) OutputTextVerbatim(page, "txt0").expect_value("500") - new_val = "36" + new_val = "20" s0.set(new_val) s0.expect_value(new_val) OutputTextVerbatim(page, "txt0").expect_value(new_val) @@ -42,7 +42,7 @@ def test_slider_range(page: Page, local_app: ShinyAppProc) -> None: s1.expect_min("1") s1.expect_max("1000") s1.expect_step("1") - s1.expect_ticks("true") + s1.expect_ticks("false") s1.expect_sep(",") s1.expect_pre(None) s1.expect_post(None) @@ -52,7 +52,7 @@ def test_slider_range(page: Page, local_app: ShinyAppProc) -> None: s1.expect_animate(exists=False) OutputTextVerbatim(page, "txt1").expect_value("(200, 500)") - new_val = ("605", "885") + new_val = ("605", "840") s1.set(new_val, max_err_values=1000) try: s1.expect_value((MISSING, MISSING)) # type: ignore @@ -97,7 +97,7 @@ def test_slider_loop(page: Page, local_app: ShinyAppProc) -> None: s3.expect_min("1") s3.expect_max("2000") s3.expect_step("10") - s3.expect_ticks("true") + s3.expect_ticks("false") s3.expect_sep(",") s3.expect_pre(None) s3.expect_post(None) @@ -131,7 +131,7 @@ def test_slider_play(page: Page, local_app: ShinyAppProc) -> None: s4.expect_min("0") s4.expect_max("5") s4.expect_step("1") - s4.expect_ticks("true") + s4.expect_ticks("false") s4.expect_sep(",") s4.expect_pre(None) s4.expect_post(None) diff --git a/e2e/inputs/test_input_slider.py b/e2e/inputs/test_input_slider.py index 6180a0ee3..fdf965889 100644 --- a/e2e/inputs/test_input_slider.py +++ b/e2e/inputs/test_input_slider.py @@ -15,9 +15,7 @@ def test_input_slider_kitchen(page: Page, slider_app: ShinyAppProc) -> None: expect(obs.loc_label).to_have_text("Number of bins:") - obs.expect_tick_labels( - ["10", "19", "28", "37", "46", "55", "64", "73", "82", "91", "100"] - ) + obs.expect_tick_labels(None) obs.expect_value("30") obs.expect_animate(False) @@ -27,7 +25,7 @@ def test_input_slider_kitchen(page: Page, slider_app: ShinyAppProc) -> None: obs.expect_max("100") # obs.expect_from() obs.expect_step("1") - obs.expect_ticks("true") + obs.expect_ticks("false") obs.expect_sep(",") obs.expect_pre(None) obs.expect_post(None) diff --git a/e2e/outputs/test_output_text.py b/e2e/outputs/test_output_text.py index 09f3f63f5..72a16c434 100644 --- a/e2e/outputs/test_output_text.py +++ b/e2e/outputs/test_output_text.py @@ -13,6 +13,8 @@ def test_output_text_kitchen(page: Page, app: ShinyAppProc) -> None: verb = OutputTextVerbatim(page, "verb") verb_no_placeholder = OutputTextVerbatim(page, "verb_no_placeholder") + txt.set("") # Reset text + text.expect_value("") text.expect_inline(False) diff --git a/examples/annotation-export/app.py b/examples/annotation-export/app.py new file mode 100644 index 000000000..6f06f4d89 --- /dev/null +++ b/examples/annotation-export/app.py @@ -0,0 +1,120 @@ +from pathlib import Path + +import matplotlib.dates as mdates +import matplotlib.pyplot as plt +import pandas as pd +import seaborn as sns + +from shiny import App, Inputs, Outputs, Session, reactive, render, ui +from shiny.plotutils import brushed_points + +path = Path(__file__).parent / "boulder_temp.csv" +weather_df = pd.read_csv(path) +weather_df["date"] = pd.to_datetime(weather_df["date"]) +weather_df["annotation"] = "" + +app_ui = ui.page_fluid( + ui.panel_title("Plot annotation example"), + ui.p( + """ + Select points to annotate them. + The plot is rendered with seaborn and all interaction is handled by Shiny. + """, + {"style": "font-size: larger"}, + ), + ui.row( + ui.column( + 6, + ui.output_plot("time_series", brush=ui.brush_opts(direction="x")), + ui.output_ui("annotator"), + ), + ui.column( + 4, + ui.h3("Annotated points"), + ui.output_data_frame("annotations"), + ), + ui.column(2, ui.download_button("download", "Download CSV")), + ), +) + + +def server(input: Inputs, output: Outputs, session: Session): + annotated_data = reactive.Value(weather_df) + + @reactive.Calc + def selected_data(): + out = brushed_points(annotated_data(), input.time_series_brush(), xvar="date") + return out + + @reactive.Effect + @reactive.event(input.annotate_button) + def _(): + selected = selected_data() + selected["annotation_new"] = input.annotation() + selected = selected.loc[:, ["date", "annotation_new"]] + + df = annotated_data().copy() + + df = df.merge(selected, on="date", how="left") + df["annotation_new"] = df["annotation_new"].fillna("") + updated_rows = df["annotation_new"] != "" + df.loc[updated_rows, "annotation"] = df.loc[updated_rows, "annotation_new"] + df = df.loc[:, ["date", "temp_c", "annotation"]] + annotated_data.set(df) + + @output + @render.plot + def time_series(): + fig, ax = plt.subplots() + ax.xaxis.set_major_locator(mdates.MonthLocator()) + ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y-%m")) + ax.set_title("Temperature readings, Boulder Colorado") + out = sns.scatterplot( + data=annotated_data(), x="date", y="temp_c", hue="annotation", ax=ax + ) + + out.tick_params(axis="x", rotation=30) + return out.get_figure() + + @output + @render.ui + def annotator(): + if input.time_series_brush() is not None: + selected = selected_data() + + min = str(selected["date"].min()) + max = str(selected["date"].max()) + + min = min.replace(" 00:00:00+00:00", "") + max = max.replace(" 00:00:00+00:00", "") + + out = ui.TagList( + ui.row( + {"style": "padding-top: 20px;"}, + ui.column( + 4, + ui.p(f"{min} to", ui.br(), f"{max}"), + ), + ui.column( + 4, + ui.input_text("annotation", "", placeholder="Enter annotation"), + ), + ui.column(4, ui.input_action_button("annotate_button", "Submit")), + ) + ) + return out + + @output + @render.data_frame + def annotations(): + df = annotated_data().copy() + df["date"] = df["date"].dt.strftime("%Y-%m-%d") + df = df.loc[df["annotation"] != ""] + return df + + @session.download(filename="data.csv") + def download(): + yield annotated_data().to_csv() + + +app = App(app_ui, server) diff --git a/examples/annotation-export/boulder_temp.csv b/examples/annotation-export/boulder_temp.csv new file mode 100644 index 000000000..b09620afb --- /dev/null +++ b/examples/annotation-export/boulder_temp.csv @@ -0,0 +1,366 @@ +date,temp_c +2021-01-01T00:00:00Z,6.1 +2021-01-02T00:00:00Z,11.1 +2021-01-03T00:00:00Z,11.7 +2021-01-04T00:00:00Z,15 +2021-01-05T00:00:00Z,12.8 +2021-01-06T00:00:00Z,7.2 +2021-01-07T00:00:00Z,7.8 +2021-01-08T00:00:00Z,7.2 +2021-01-09T00:00:00Z,2.2 +2021-01-10T00:00:00Z,1.7 +2021-01-11T00:00:00Z,6.7 +2021-01-12T00:00:00Z,12.2 +2021-01-13T00:00:00Z,17.8 +2021-01-14T00:00:00Z,13.3 +2021-01-15T00:00:00Z,7.8 +2021-01-16T00:00:00Z,11.1 +2021-01-17T00:00:00Z,11.1 +2021-01-18T00:00:00Z,7.8 +2021-01-19T00:00:00Z,3.9 +2021-01-20T00:00:00Z,15 +2021-01-21T00:00:00Z,8.3 +2021-01-22T00:00:00Z,7.2 +2021-01-23T00:00:00Z,6.7 +2021-01-24T00:00:00Z,3.3 +2021-01-25T00:00:00Z,3.3 +2021-01-26T00:00:00Z,-2.2 +2021-01-27T00:00:00Z,-2.2 +2021-01-28T00:00:00Z,7.2 +2021-01-29T00:00:00Z,14.4 +2021-01-30T00:00:00Z,12.2 +2021-01-31T00:00:00Z,11.1 +2021-02-01T00:00:00Z,10 +2021-02-02T00:00:00Z,18.3 +2021-02-03T00:00:00Z,18.3 +2021-02-04T00:00:00Z,7.8 +2021-02-05T00:00:00Z,7.2 +2021-02-06T00:00:00Z,7.2 +2021-02-07T00:00:00Z,12.2 +2021-02-08T00:00:00Z,8.9 +2021-02-09T00:00:00Z,-0.6 +2021-02-10T00:00:00Z,-0.6 +2021-02-11T00:00:00Z,-2.8 +2021-02-12T00:00:00Z,-9.4 +2021-02-13T00:00:00Z,-13.9 +2021-02-14T00:00:00Z,-15.6 +2021-02-15T00:00:00Z,-6.1 +2021-02-16T00:00:00Z,5.6 +2021-02-17T00:00:00Z,1.7 +2021-02-18T00:00:00Z,1.1 +2021-02-19T00:00:00Z,7.2 +2021-02-20T00:00:00Z,10 +2021-02-21T00:00:00Z,6.1 +2021-02-22T00:00:00Z,13.3 +2021-02-23T00:00:00Z,15.6 +2021-02-24T00:00:00Z,12.2 +2021-02-25T00:00:00Z,1.7 +2021-02-26T00:00:00Z,7.2 +2021-02-27T00:00:00Z,5 +2021-02-28T00:00:00Z,3.9 +2021-03-01T00:00:00Z,9.4 +2021-03-02T00:00:00Z,16.7 +2021-03-03T00:00:00Z,16.7 +2021-03-04T00:00:00Z,8.9 +2021-03-05T00:00:00Z,15.6 +2021-03-06T00:00:00Z,19.4 +2021-03-07T00:00:00Z,20.6 +2021-03-08T00:00:00Z,20 +2021-03-09T00:00:00Z,20.6 +2021-03-10T00:00:00Z,15.6 +2021-03-11T00:00:00Z,6.7 +2021-03-12T00:00:00Z,5.6 +2021-03-13T00:00:00Z,4.4 +2021-03-14T00:00:00Z,0 +2021-03-15T00:00:00Z,4.4 +2021-03-16T00:00:00Z,4.4 +2021-03-17T00:00:00Z,7.8 +2021-03-18T00:00:00Z,7.8 +2021-03-19T00:00:00Z,14.4 +2021-03-20T00:00:00Z,17.8 +2021-03-21T00:00:00Z,11.1 +2021-03-22T00:00:00Z,4.4 +2021-03-23T00:00:00Z,5 +2021-03-24T00:00:00Z,4.4 +2021-03-25T00:00:00Z,10 +2021-03-26T00:00:00Z,9.4 +2021-03-27T00:00:00Z,12.2 +2021-03-28T00:00:00Z,17.2 +2021-03-29T00:00:00Z,21.7 +2021-03-30T00:00:00Z,16.7 +2021-03-31T00:00:00Z,12.2 +2021-04-01T00:00:00Z,21.7 +2021-04-02T00:00:00Z,23.3 +2021-04-03T00:00:00Z,25.6 +2021-04-04T00:00:00Z,26.7 +2021-04-05T00:00:00Z,25 +2021-04-06T00:00:00Z,24.4 +2021-04-07T00:00:00Z,17.2 +2021-04-08T00:00:00Z,21.1 +2021-04-09T00:00:00Z,19.4 +2021-04-10T00:00:00Z,21.1 +2021-04-11T00:00:00Z,18.3 +2021-04-12T00:00:00Z,11.7 +2021-04-13T00:00:00Z,3.9 +2021-04-14T00:00:00Z,3.9 +2021-04-15T00:00:00Z,3.9 +2021-04-16T00:00:00Z,3.3 +2021-04-17T00:00:00Z,6.7 +2021-04-18T00:00:00Z,14.4 +2021-04-19T00:00:00Z,12.2 +2021-04-20T00:00:00Z,2.8 +2021-04-21T00:00:00Z,1.7 +2021-04-22T00:00:00Z,10 +2021-04-23T00:00:00Z,13.9 +2021-04-24T00:00:00Z,17.8 +2021-04-25T00:00:00Z,26.1 +2021-04-26T00:00:00Z,24.4 +2021-04-27T00:00:00Z,20.6 +2021-04-28T00:00:00Z,15 +2021-04-29T00:00:00Z,20.6 +2021-04-30T00:00:00Z,26.1 +2021-05-01T00:00:00Z,28.9 +2021-05-02T00:00:00Z,26.1 +2021-05-03T00:00:00Z,12.2 +2021-05-04T00:00:00Z,15.6 +2021-05-05T00:00:00Z,15.6 +2021-05-06T00:00:00Z,20.6 +2021-05-07T00:00:00Z,27.8 +2021-05-08T00:00:00Z,24.4 +2021-05-09T00:00:00Z,16.1 +2021-05-10T00:00:00Z,7.2 +2021-05-11T00:00:00Z,5.6 +2021-05-12T00:00:00Z,16.7 +2021-05-13T00:00:00Z,23.9 +2021-05-14T00:00:00Z,22.2 +2021-05-15T00:00:00Z,19.4 +2021-05-16T00:00:00Z,15 +2021-05-17T00:00:00Z,17.2 +2021-05-18T00:00:00Z,17.8 +2021-05-19T00:00:00Z,21.7 +2021-05-20T00:00:00Z,25.6 +2021-05-21T00:00:00Z,24.4 +2021-05-22T00:00:00Z,22.8 +2021-05-23T00:00:00Z,23.9 +2021-05-24T00:00:00Z,22.8 +2021-05-25T00:00:00Z,24.4 +2021-05-26T00:00:00Z,25.6 +2021-05-27T00:00:00Z,25 +2021-05-28T00:00:00Z,26.1 +2021-05-29T00:00:00Z,21.7 +2021-05-30T00:00:00Z,17.8 +2021-05-31T00:00:00Z,13.9 +2021-06-01T00:00:00Z,20 +2021-06-02T00:00:00Z,23.3 +2021-06-03T00:00:00Z,27.8 +2021-06-04T00:00:00Z,30.6 +2021-06-05T00:00:00Z,32.8 +2021-06-06T00:00:00Z,31.1 +2021-06-07T00:00:00Z,29.4 +2021-06-08T00:00:00Z,31.1 +2021-06-09T00:00:00Z,31.7 +2021-06-10T00:00:00Z,32.8 +2021-06-11T00:00:00Z,32.2 +2021-06-12T00:00:00Z,30 +2021-06-13T00:00:00Z,33.3 +2021-06-14T00:00:00Z,34.4 +2021-06-15T00:00:00Z,35.6 +2021-06-16T00:00:00Z,37.2 +2021-06-17T00:00:00Z,37.2 +2021-06-18T00:00:00Z,32.8 +2021-06-19T00:00:00Z,31.7 +2021-06-20T00:00:00Z,27.2 +2021-06-21T00:00:00Z,23.9 +2021-06-22T00:00:00Z,33.9 +2021-06-23T00:00:00Z,35.6 +2021-06-24T00:00:00Z,29.4 +2021-06-25T00:00:00Z,25 +2021-06-26T00:00:00Z,20.6 +2021-06-27T00:00:00Z,22.2 +2021-06-28T00:00:00Z,22.2 +2021-06-29T00:00:00Z,24.4 +2021-06-30T00:00:00Z,27.2 +2021-07-01T00:00:00Z,25 +2021-07-02T00:00:00Z,28.3 +2021-07-03T00:00:00Z,30.6 +2021-07-04T00:00:00Z,29.4 +2021-07-05T00:00:00Z,30 +2021-07-06T00:00:00Z,28.3 +2021-07-07T00:00:00Z,30.6 +2021-07-08T00:00:00Z,36.7 +2021-07-09T00:00:00Z,34.4 +2021-07-10T00:00:00Z,32.8 +2021-07-11T00:00:00Z,28.9 +2021-07-12T00:00:00Z,31.1 +2021-07-13T00:00:00Z,30.6 +2021-07-14T00:00:00Z,25.6 +2021-07-15T00:00:00Z,27.8 +2021-07-16T00:00:00Z,32.2 +2021-07-17T00:00:00Z,32.8 +2021-07-18T00:00:00Z,32.8 +2021-07-19T00:00:00Z,33.3 +2021-07-20T00:00:00Z,33.9 +2021-07-21T00:00:00Z,31.7 +2021-07-22T00:00:00Z,34.4 +2021-07-23T00:00:00Z,33.9 +2021-07-24T00:00:00Z,30 +2021-07-25T00:00:00Z,32.2 +2021-07-26T00:00:00Z,32.8 +2021-07-27T00:00:00Z,33.9 +2021-07-28T00:00:00Z,35 +2021-07-29T00:00:00Z,31.7 +2021-07-30T00:00:00Z,32.8 +2021-07-31T00:00:00Z,24.4 +2021-08-01T00:00:00Z,26.7 +2021-08-02T00:00:00Z,29.4 +2021-08-03T00:00:00Z,27.8 +2021-08-04T00:00:00Z,27.2 +2021-08-05T00:00:00Z,32.8 +2021-08-06T00:00:00Z,32.2 +2021-08-07T00:00:00Z,27.2 +2021-08-08T00:00:00Z,32.8 +2021-08-09T00:00:00Z,34.4 +2021-08-10T00:00:00Z,34.4 +2021-08-11T00:00:00Z,33.9 +2021-08-12T00:00:00Z,30 +2021-08-13T00:00:00Z,28.9 +2021-08-14T00:00:00Z,33.9 +2021-08-15T00:00:00Z,31.7 +2021-08-16T00:00:00Z,32.8 +2021-08-17T00:00:00Z,35 +2021-08-18T00:00:00Z,33.3 +2021-08-19T00:00:00Z,27.8 +2021-08-20T00:00:00Z,26.1 +2021-08-21T00:00:00Z,27.8 +2021-08-22T00:00:00Z,32.2 +2021-08-23T00:00:00Z,32.2 +2021-08-24T00:00:00Z,33.9 +2021-08-25T00:00:00Z,31.1 +2021-08-26T00:00:00Z,28.3 +2021-08-27T00:00:00Z,32.8 +2021-08-28T00:00:00Z,31.7 +2021-08-29T00:00:00Z,28.3 +2021-08-30T00:00:00Z,32.8 +2021-08-31T00:00:00Z,34.4 +2021-09-01T00:00:00Z,30.6 +2021-09-02T00:00:00Z,27.8 +2021-09-03T00:00:00Z,27.2 +2021-09-04T00:00:00Z,28.9 +2021-09-05T00:00:00Z,30.6 +2021-09-06T00:00:00Z,35 +2021-09-07T00:00:00Z,30 +2021-09-08T00:00:00Z,30.6 +2021-09-09T00:00:00Z,35 +2021-09-10T00:00:00Z,37.2 +2021-09-11T00:00:00Z,35 +2021-09-12T00:00:00Z,29.4 +2021-09-13T00:00:00Z,28.3 +2021-09-14T00:00:00Z,25.6 +2021-09-15T00:00:00Z,32.2 +2021-09-16T00:00:00Z,32.8 +2021-09-17T00:00:00Z,23.3 +2021-09-18T00:00:00Z,32.8 +2021-09-19T00:00:00Z,31.1 +2021-09-20T00:00:00Z,26.1 +2021-09-21T00:00:00Z,18.9 +2021-09-22T00:00:00Z,26.1 +2021-09-23T00:00:00Z,28.3 +2021-09-24T00:00:00Z,22.8 +2021-09-25T00:00:00Z,30.6 +2021-09-26T00:00:00Z,32.2 +2021-09-27T00:00:00Z,31.1 +2021-09-28T00:00:00Z,27.8 +2021-09-29T00:00:00Z,21.7 +2021-09-30T00:00:00Z,12.2 +2021-10-01T00:00:00Z,19.4 +2021-10-02T00:00:00Z,22.2 +2021-10-03T00:00:00Z,26.1 +2021-10-04T00:00:00Z,27.8 +2021-10-05T00:00:00Z,28.3 +2021-10-06T00:00:00Z,25 +2021-10-07T00:00:00Z,26.1 +2021-10-08T00:00:00Z,24.4 +2021-10-09T00:00:00Z,20 +2021-10-10T00:00:00Z,21.1 +2021-10-11T00:00:00Z,21.1 +2021-10-12T00:00:00Z,12.2 +2021-10-13T00:00:00Z,16.1 +2021-10-14T00:00:00Z,10 +2021-10-15T00:00:00Z,13.3 +2021-10-16T00:00:00Z,21.7 +2021-10-17T00:00:00Z,23.9 +2021-10-18T00:00:00Z,24.4 +2021-10-19T00:00:00Z,18.3 +2021-10-20T00:00:00Z,18.3 +2021-10-21T00:00:00Z,17.2 +2021-10-22T00:00:00Z,23.9 +2021-10-23T00:00:00Z,22.8 +2021-10-24T00:00:00Z,21.1 +2021-10-25T00:00:00Z,22.8 +2021-10-26T00:00:00Z,21.7 +2021-10-27T00:00:00Z,15 +2021-10-28T00:00:00Z,15 +2021-10-29T00:00:00Z,24.4 +2021-10-30T00:00:00Z,24.4 +2021-10-31T00:00:00Z,12.8 +2021-11-01T00:00:00Z,3.9 +2021-11-02T00:00:00Z,6.7 +2021-11-03T00:00:00Z,16.1 +2021-11-04T00:00:00Z,21.1 +2021-11-05T00:00:00Z,23.9 +2021-11-06T00:00:00Z,23.9 +2021-11-07T00:00:00Z,25.6 +2021-11-08T00:00:00Z,12.8 +2021-11-09T00:00:00Z,12.8 +2021-11-10T00:00:00Z,13.9 +2021-11-11T00:00:00Z,12.8 +2021-11-12T00:00:00Z,13.3 +2021-11-13T00:00:00Z,19.4 +2021-11-14T00:00:00Z,18.9 +2021-11-15T00:00:00Z,22.8 +2021-11-16T00:00:00Z,22.2 +2021-11-17T00:00:00Z,15 +2021-11-18T00:00:00Z,10.6 +2021-11-19T00:00:00Z,18.9 +2021-11-20T00:00:00Z,14.4 +2021-11-21T00:00:00Z,14.4 +2021-11-22T00:00:00Z,21.1 +2021-11-23T00:00:00Z,21.1 +2021-11-24T00:00:00Z,13.9 +2021-11-25T00:00:00Z,16.1 +2021-11-26T00:00:00Z,22.2 +2021-11-27T00:00:00Z,14.4 +2021-11-28T00:00:00Z,19.4 +2021-11-29T00:00:00Z,23.3 +2021-11-30T00:00:00Z,15 +2021-12-01T00:00:00Z,22.2 +2021-12-02T00:00:00Z,22.2 +2021-12-03T00:00:00Z,16.1 +2021-12-04T00:00:00Z,20.6 +2021-12-05T00:00:00Z,17.8 +2021-12-06T00:00:00Z,-0.6 +2021-12-07T00:00:00Z,13.3 +2021-12-08T00:00:00Z,13.3 +2021-12-09T00:00:00Z,10.6 +2021-12-10T00:00:00Z,5.6 +2021-12-11T00:00:00Z,9.4 +2021-12-12T00:00:00Z,18.3 +2021-12-13T00:00:00Z,13.3 +2021-12-14T00:00:00Z,16.7 +2021-12-15T00:00:00Z,13.3 +2021-12-16T00:00:00Z,7.8 +2021-12-17T00:00:00Z,7.2 +2021-12-18T00:00:00Z,3.9 +2021-12-19T00:00:00Z,16.7 +2021-12-20T00:00:00Z,16.7 +2021-12-21T00:00:00Z,17.2 +2021-12-22T00:00:00Z,17.2 +2021-12-23T00:00:00Z,17.2 +2021-12-24T00:00:00Z,12.8 +2021-12-25T00:00:00Z,12.2 +2021-12-26T00:00:00Z,11.1 +2021-12-27T00:00:00Z,6.7 +2021-12-28T00:00:00Z,2.8 +2021-12-29T00:00:00Z,0.6 +2021-12-30T00:00:00Z,6.7 +2021-12-31T00:00:00Z,6.7 diff --git a/examples/annotation-export/requirements.txt b/examples/annotation-export/requirements.txt new file mode 100644 index 000000000..4695c7070 --- /dev/null +++ b/examples/annotation-export/requirements.txt @@ -0,0 +1,2 @@ +seaborn +pandas diff --git a/examples/penguins/app.py b/examples/penguins/app.py index 863aee746..e7bbe5919 100644 --- a/examples/penguins/app.py +++ b/examples/penguins/app.py @@ -1,26 +1,16 @@ # TODO-future: Add filter of X varaible to reduce the data? (Here we would show "Gentoo" has count 0, rather than remove if no data exists) # TODO-future: Add brushing to zoom into the plot. The counts should represent the data in the zoomed area. (Single click would zoom out) -import warnings from pathlib import Path from typing import List -import matplotlib import pandas as pd import seaborn as sns -import shinyswatch from colors import bg_palette, palette import shiny.experimental as x from shiny import App, Inputs, Outputs, Session, reactive, render, req, ui -# There is a matplotlib bug which causes CI failures -# see https://github.com/rstudio/py-shiny/issues/611#issuecomment-1632866419 -if matplotlib.__version__ == "3.7.2": - warnings.filterwarnings( - "ignore", category=UserWarning, message="The figure layout has changed to tight" - ) - sns.set_theme() www_dir = Path(__file__).parent.resolve() / "www" @@ -53,7 +43,6 @@ ui.input_switch("by_species", "Show species", value=True), ui.input_switch("show_margins", "Show marginal plots", value=True), ), - shinyswatch.theme.pulse(), ui.output_ui("value_boxes"), x.ui.output_plot("scatter", fill=True), ) diff --git a/js/package-lock.json b/js/package-lock.json index b097bd913..9615ae8a8 100644 --- a/js/package-lock.json +++ b/js/package-lock.json @@ -6,7 +6,6 @@ "packages": { "": { "name": "shiny-dataframe-binding", - "version": "1.0.0", "license": "MIT", "dependencies": { "@tanstack/react-table": "^8.9.1" diff --git a/js/package.json b/js/package.json index 13cd817a0..6fe3cfb25 100644 --- a/js/package.json +++ b/js/package.json @@ -1,14 +1,12 @@ { "name": "shiny-dataframe-binding", - "version": "1.0.0", - "description": "", + "private": true, + "license": "MIT", "main": "index.js", "scripts": { "build": "tsc -noEmit && eslint . && tsx build.ts", "watch": "npx nodemon --exec 'npm run build' --ext '*' --ignore dist/ --ignore esbuild-metadata.json" }, - "author": "", - "license": "MIT", "devDependencies": { "@preact/compat": "^17.1.2", "@tanstack/react-virtual": "^3.0.0-beta.54", diff --git a/pyrightconfig.json b/pyrightconfig.json index baf6b8057..6f6e3dcc4 100644 --- a/pyrightconfig.json +++ b/pyrightconfig.json @@ -1,6 +1,6 @@ { "ignore": [ - "shiny/examples", + "shiny/api-examples", "examples", "build", "dist", diff --git a/scripts/htmlDependencies.R b/scripts/htmlDependencies.R index 78af77030..512a659ef 100755 --- a/scripts/htmlDependencies.R +++ b/scripts/htmlDependencies.R @@ -1,10 +1,18 @@ #!/usr/bin/env Rscript +message("Checking for node / npm") +if (Sys.which("npm")[["npm"]] == "") { + stop("Please install node / npm before running script") +} + versions <- list() # Use local lib path for installing packages so we don't pollute the user's library +message("Installing GitHub packages: bslib, shiny, htmltools") withr::local_temp_libpaths() -pak::pkg_install(c("rstudio/bslib@main", "rstudio/shiny@main", "rstudio/htmltools@main")) +ignore <- capture.output({ + pak::pkg_install(c("rstudio/bslib@main", "rstudio/shiny@main", "rstudio/htmltools@main")) +}) # pak::pkg_install(c("cran::bslib", "cran::shiny", "cran::htmltools")) versions["shiny_html_deps"] <- as.character(packageVersion("shiny")) @@ -33,8 +41,8 @@ bslib_version <- pkg_source_version("bslib") shiny_version <- pkg_source_version("shiny") htmltools_version <- pkg_source_version("htmltools") -library(htmltools) -library(bslib) +library(htmltools, quietly = TRUE, warn.conflicts = FALSE) +library(bslib, quietly = TRUE, warn.conflicts = FALSE) shiny_path <- fs::path(getwd(), "shiny") www <- fs::path(shiny_path, "www") @@ -77,24 +85,36 @@ copy_from_pkg <- function(pkg_name, pkg_dir, local_dir, version_dir = fs::path_d } +# ------------------------------------------------------------------------------ +message("Copy bslib components") # Copy over bslib's components directory -copy_from_pkg("bslib", "components", x_www_bslib_components) -# Remove unused Sass files +copy_from_pkg("bslib", "components/dist", x_www_bslib_components) +# Remove non-minified files fs::file_delete( - fs::dir_ls(x_www_bslib_components, type = "file", regexp = "\\.scss$") + fs::dir_ls( + x_www_bslib_components, + type = "file", + recurse = TRUE, + regexp = "\\.(min\\.|css)", + invert = TRUE + ) ) -# Remove unused tag require -fs::file_delete(fs::path(x_www_bslib_components, "tag-require.js")) + +# ------------------------------------------------------------------------------ +message("Copy htmltools - fill") # Copy over htmltools's fill directory copy_from_pkg("htmltools", "fill", fs::path(x_www, "htmltools", "fill")) - - +# ------------------------------------------------------------------------------ +message("Copy shiny www/shared") # Copy over shiny's www/shared directory copy_from_pkg("shiny", "www/shared", www_shared, www_shared) + +# ------------------------------------------------------------------------------ +message("Cleanup shiny www/shared") # Don't need legacy (hopefully) fs::dir_delete(fs::path(www_shared, "legacy")) # Don't need dataTables (hopefully) @@ -105,8 +125,40 @@ fs::file_delete( fs::dir_ls(www_shared, type = "file", regexp = "jquery") ) + +# ------------------------------------------------------------------------------ +message("Save ionRangeSlider dep") + # Upgrade to Bootstrap 5 by default -deps <- bs_theme_dependencies(bs_theme(version = 5)) +shiny_theme <- bslib::bs_theme(version = 5, preset = "shiny") +# Save iorange slider dep +# Get _dynamic_ ionrangeslider dep +ion_dep <- shiny:::ionRangeSliderDependencyCSS(shiny_theme) +if (inherits(ion_dep, "html_dependency")) { + ion_dep <- list(ion_dep) +} +# Save to temp folder +temp_ion_dep_dir <- fs::path_temp("shiny-ion-range-slider") +fs::dir_create(temp_ion_dep_dir) +withr::with_options( + list(htmltools.dir.version = FALSE), + ignore <- lapply(ion_dep, htmltools::copyDependencyToDir, temp_ion_dep_dir) +) +# Overwrite css file +ion_dep_dir <- fs::path(www_shared, "ionrangeslider") +fs::file_move( + fs::path(temp_ion_dep_dir, "ionRangeSlider", "ionRangeSlider.css"), + fs::path(ion_dep_dir, "css", "ion.rangeSlider.css") +) +# Cleanup +fs::dir_delete(temp_ion_dep_dir) + +# TODO - make a UI object and save its dependencies. Loop through the different UI elements of bslib and save them accordingly. Similar to shiny::input_slider? instead of reaching into the `:::` + + +message("Save bootstrap bundle") +# Save htmldeps +deps <- bslib::bs_theme_dependencies(shiny_theme) withr::with_options( list(htmltools.dir.version = FALSE), ignore <- lapply(deps, copyDependencyToDir, www_shared) @@ -123,6 +175,19 @@ write_json( ) ) +message("Reduce font files") +font_txt <- unlist(strsplit(readLines("shiny/www/shared/bootstrap/font.css"), ";")) +woff_files <- list.files("shiny/www/shared/bootstrap/fonts", pattern = "\\.woff", full.names = TRUE) +ignored <- lapply(woff_files, function(woff_file) { + file_name <- basename(woff_file) + if (!any(grepl(file_name, font_txt, fixed = TRUE))) { + unlink(woff_file) + } +}) + + +# ------------------------------------------------------------------------------ +message("Cleanup bootstrap bundle") # This additional bs3compat HTMLDependency() only holds # the JS shim for tab panel logic, which we don't need # since we're generating BS5+ tab markup. Note, however, @@ -130,13 +195,17 @@ write_json( # comes in via the bootstrap HTMLDependency() fs::dir_delete(fs::path(www_shared, "bs3compat")) + +# ------------------------------------------------------------------------------ +message("Save requirejs") requirejs_version <- "2.3.6" versions["requirejs"] <- requirejs_version 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"), - fs::path(requirejs, "require.min.js") + fs::path(requirejs, "require.min.js"), + quiet = TRUE ) shims <- fs::path(getwd(), "scripts", "define-shims.js") @@ -149,6 +218,8 @@ cat( ) +# ------------------------------------------------------------------------------ +message("Save _versions.py") version_vars <- paste0(names(versions), " = ", "\"", versions, "\"\n", collapse = "") version_all <- paste0( collapse = "", @@ -165,9 +236,8 @@ cat( ) - # ------------------------------------------------------------------------------ -# Copy x assets to shiny main_x assets +message("Copy www/shared/_x assets") if (fs::dir_exists(main_x_www)) fs::dir_delete(main_x_www) @@ -183,7 +253,17 @@ fs::dir_copy(x_www_htmltools_fill, main_x_htmltools_fill) fs::file_delete( fs::dir_ls( fs::path(main_x_bslib_components, "components"), - regexp="(_version|sidebar)", + regexp="(_version|sidebar|nav_spacer)", invert = TRUE ) ) + + +# ------------------------------------------------------------------------------ +message("Create dataframe.js via npm") + +js_path <- fs::path_abs(fs::path(shiny_path, "..", "js")) +ignore <- system( + paste0("cd ", js_path, " && npm install && npm run build"), + intern = TRUE +) diff --git a/shiny/__init__.py b/shiny/__init__.py index 48747ab82..d5698b83b 100644 --- a/shiny/__init__.py +++ b/shiny/__init__.py @@ -1,6 +1,6 @@ """A package for building reactive web applications.""" -__version__ = "0.4.0.9000" +__version__ = "0.4.0.9001" from ._shinyenv import is_pyodide as _is_pyodide diff --git a/shiny/_app.py b/shiny/_app.py index 0a45d4592..4715a2a93 100644 --- a/shiny/_app.py +++ b/shiny/_app.py @@ -53,16 +53,17 @@ class App: Example ------- - .. code-block:: python + ```{python} + #| eval: false + from shiny import App, Inputs, Outputs, Session, ui - from shiny import App, Inputs, Outputs, Session, ui + app_ui = ui.page_fluid("Hello Shiny!") - app_ui = ui.page_fluid("Hello Shiny!") - - def server(input: Inputs, output: Outputs, session: Session): - pass + def server(input: Inputs, output: Outputs, session: Session): + pass - app = App(app_ui, server) + app = App(app_ui, server) + ``` """ lib_prefix: str = "lib/" diff --git a/shiny/_docstring.py b/shiny/_docstring.py index 2abd578b4..699fcc450 100644 --- a/shiny/_docstring.py +++ b/shiny/_docstring.py @@ -4,7 +4,7 @@ import os from typing import Any, Callable, Literal, TypeVar -ex_dir: str = os.path.join(os.path.dirname(os.path.abspath(__file__)), "examples") +ex_dir: str = os.path.join(os.path.dirname(os.path.abspath(__file__)), "api-examples") FuncType = Callable[..., Any] F = TypeVar("F", bound=FuncType) @@ -32,7 +32,7 @@ def add_example( Add an example to the docstring of a function, method, or class. This decorator must, at the moment, be used on a function, method, or class whose - ``__name__`` matches the name of directory under ``shiny/examples/``, and must + ``__name__`` matches the name of directory under ``shiny/api-examples/``, and must also contain a ``app.py`` file in that directory. Parameters diff --git a/shiny/_main.py b/shiny/_main.py index 66aa0fa34..f53e51392 100644 --- a/shiny/_main.py +++ b/shiny/_main.py @@ -207,21 +207,22 @@ def run_app( Examples -------- - .. code-block:: python + ```{python} + #|eval: false + from shiny import run_app - from shiny import run_app + # Run ``app`` inside ``./app.py`` + run_app() - # Run ``app`` inside ``./app.py`` - run_app() + # Run ``app`` inside ``./myapp.py`` (or ``./myapp/app.py``) + run_app("myapp") - # Run ``app`` inside ``./myapp.py`` (or ``./myapp/app.py``) - run_app("myapp") + # Run ``my_app`` inside ``./myapp.py`` (or ``./myapp/app.py``) + run_app("myapp:my_app") - # Run ``my_app`` inside ``./myapp.py`` (or ``./myapp/app.py``) - run_app("myapp:my_app") - - # Run ``my_app`` inside ``../myapp.py`` (or ``../myapp/app.py``) - run_app("myapp:my_app", app_dir="..") + # Run ``my_app`` inside ``../myapp.py`` (or ``../myapp/app.py``) + run_app("myapp:my_app", app_dir="..") + ``` """ # If port is 0, randomize @@ -360,18 +361,20 @@ def resolve_app(app: str, app_dir: Optional[str]) -> tuple[str, Optional[str]]: attr = "app" if is_file(module): + # Before checking module path, resolve it relative to app_dir if provided + module_path = module if app_dir is None else os.path.join(app_dir, module) # TODO: We should probably be using some kind of loader # TODO: I don't like that we exit here, if we ever export this it would be bad; # but also printing a massive stack trace for a `shiny run badpath` is way # unfriendly. We should probably throw a custom error that the shiny run # entrypoint knows not to print the stack trace for. - if not os.path.exists(module): - sys.stderr.write(f"Error: {module} not found\n") + if not os.path.exists(module_path): + sys.stderr.write(f"Error: {module_path} not found\n") sys.exit(1) - if not os.path.isfile(module): - sys.stderr.write(f"Error: {module} is not a file\n") + if not os.path.isfile(module_path): + sys.stderr.write(f"Error: {module_path} is not a file\n") sys.exit(1) - dirname, filename = os.path.split(module) + dirname, filename = os.path.split(module_path) module = filename[:-3] if filename.endswith(".py") else filename app_dir = dirname @@ -419,7 +422,7 @@ def create(appdir: str) -> None: app_dir.mkdir() shutil.copyfile( - Path(__file__).parent / "examples" / "template" / "app.py", app_path + Path(__file__).parent / "api-examples" / "template" / "app.py", app_path ) print(f"Created Shiny app at {app_dir / 'app.py'}") diff --git a/shiny/examples/Calc/app.py b/shiny/api-examples/Calc/app.py similarity index 100% rename from shiny/examples/Calc/app.py rename to shiny/api-examples/Calc/app.py diff --git a/shiny/examples/Effect/app.py b/shiny/api-examples/Effect/app.py similarity index 100% rename from shiny/examples/Effect/app.py rename to shiny/api-examples/Effect/app.py diff --git a/shiny/examples/Module/app.py b/shiny/api-examples/Module/app.py similarity index 100% rename from shiny/examples/Module/app.py rename to shiny/api-examples/Module/app.py diff --git a/shiny/examples/Progress/app.py b/shiny/api-examples/Progress/app.py similarity index 100% rename from shiny/examples/Progress/app.py rename to shiny/api-examples/Progress/app.py diff --git a/shiny/examples/SafeException/app.py b/shiny/api-examples/SafeException/app.py similarity index 100% rename from shiny/examples/SafeException/app.py rename to shiny/api-examples/SafeException/app.py diff --git a/shiny/examples/SilentCancelOutputException/app.py b/shiny/api-examples/SilentCancelOutputException/app.py similarity index 100% rename from shiny/examples/SilentCancelOutputException/app.py rename to shiny/api-examples/SilentCancelOutputException/app.py diff --git a/shiny/examples/SilentException/app.py b/shiny/api-examples/SilentException/app.py similarity index 100% rename from shiny/examples/SilentException/app.py rename to shiny/api-examples/SilentException/app.py diff --git a/shiny/examples/Value/app.py b/shiny/api-examples/Value/app.py similarity index 100% rename from shiny/examples/Value/app.py rename to shiny/api-examples/Value/app.py diff --git a/shiny/examples/close/app.py b/shiny/api-examples/close/app.py similarity index 100% rename from shiny/examples/close/app.py rename to shiny/api-examples/close/app.py diff --git a/shiny/examples/data_frame/app.py b/shiny/api-examples/data_frame/app.py similarity index 100% rename from shiny/examples/data_frame/app.py rename to shiny/api-examples/data_frame/app.py diff --git a/shiny/examples/download/app.py b/shiny/api-examples/download/app.py similarity index 100% rename from shiny/examples/download/app.py rename to shiny/api-examples/download/app.py diff --git a/shiny/examples/download/mtcars.csv b/shiny/api-examples/download/mtcars.csv similarity index 100% rename from shiny/examples/download/mtcars.csv rename to shiny/api-examples/download/mtcars.csv diff --git a/shiny/examples/download_button/app.py b/shiny/api-examples/download_button/app.py similarity index 100% rename from shiny/examples/download_button/app.py rename to shiny/api-examples/download_button/app.py diff --git a/shiny/examples/download_button/mtcars.csv b/shiny/api-examples/download_button/mtcars.csv similarity index 100% rename from shiny/examples/download_button/mtcars.csv rename to shiny/api-examples/download_button/mtcars.csv diff --git a/shiny/examples/download_link/app.py b/shiny/api-examples/download_link/app.py similarity index 100% rename from shiny/examples/download_link/app.py rename to shiny/api-examples/download_link/app.py diff --git a/shiny/examples/download_link/mtcars.csv b/shiny/api-examples/download_link/mtcars.csv similarity index 100% rename from shiny/examples/download_link/mtcars.csv rename to shiny/api-examples/download_link/mtcars.csv diff --git a/shiny/examples/dynamic_route/app.py b/shiny/api-examples/dynamic_route/app.py similarity index 100% rename from shiny/examples/dynamic_route/app.py rename to shiny/api-examples/dynamic_route/app.py diff --git a/shiny/examples/event/app.py b/shiny/api-examples/event/app.py similarity index 100% rename from shiny/examples/event/app.py rename to shiny/api-examples/event/app.py diff --git a/shiny/examples/file_reader/app.py b/shiny/api-examples/file_reader/app.py similarity index 100% rename from shiny/examples/file_reader/app.py rename to shiny/api-examples/file_reader/app.py diff --git a/shiny/examples/file_reader/mtcars.csv b/shiny/api-examples/file_reader/mtcars.csv similarity index 100% rename from shiny/examples/file_reader/mtcars.csv rename to shiny/api-examples/file_reader/mtcars.csv diff --git a/shiny/examples/include_css/app.py b/shiny/api-examples/include_css/app.py similarity index 100% rename from shiny/examples/include_css/app.py rename to shiny/api-examples/include_css/app.py diff --git a/shiny/examples/include_css/css/styles.css b/shiny/api-examples/include_css/css/styles.css similarity index 100% rename from shiny/examples/include_css/css/styles.css rename to shiny/api-examples/include_css/css/styles.css diff --git a/shiny/examples/include_javascript/app.py b/shiny/api-examples/include_javascript/app.py similarity index 100% rename from shiny/examples/include_javascript/app.py rename to shiny/api-examples/include_javascript/app.py diff --git a/shiny/examples/include_javascript/js/app.js b/shiny/api-examples/include_javascript/js/app.js similarity index 100% rename from shiny/examples/include_javascript/js/app.js rename to shiny/api-examples/include_javascript/js/app.js diff --git a/shiny/examples/input_action_button/app.py b/shiny/api-examples/input_action_button/app.py similarity index 100% rename from shiny/examples/input_action_button/app.py rename to shiny/api-examples/input_action_button/app.py diff --git a/shiny/examples/input_action_link/app.py b/shiny/api-examples/input_action_link/app.py similarity index 100% rename from shiny/examples/input_action_link/app.py rename to shiny/api-examples/input_action_link/app.py diff --git a/shiny/examples/input_checkbox/app.py b/shiny/api-examples/input_checkbox/app.py similarity index 100% rename from shiny/examples/input_checkbox/app.py rename to shiny/api-examples/input_checkbox/app.py diff --git a/shiny/examples/input_checkbox_group/app.py b/shiny/api-examples/input_checkbox_group/app.py similarity index 100% rename from shiny/examples/input_checkbox_group/app.py rename to shiny/api-examples/input_checkbox_group/app.py diff --git a/shiny/examples/input_date/app.py b/shiny/api-examples/input_date/app.py similarity index 100% rename from shiny/examples/input_date/app.py rename to shiny/api-examples/input_date/app.py diff --git a/shiny/examples/input_date_range/app.py b/shiny/api-examples/input_date_range/app.py similarity index 100% rename from shiny/examples/input_date_range/app.py rename to shiny/api-examples/input_date_range/app.py diff --git a/shiny/examples/input_file/app.py b/shiny/api-examples/input_file/app.py similarity index 100% rename from shiny/examples/input_file/app.py rename to shiny/api-examples/input_file/app.py diff --git a/shiny/examples/input_numeric/app.py b/shiny/api-examples/input_numeric/app.py similarity index 100% rename from shiny/examples/input_numeric/app.py rename to shiny/api-examples/input_numeric/app.py diff --git a/shiny/examples/input_password/app.py b/shiny/api-examples/input_password/app.py similarity index 100% rename from shiny/examples/input_password/app.py rename to shiny/api-examples/input_password/app.py diff --git a/shiny/examples/input_radio_buttons/app.py b/shiny/api-examples/input_radio_buttons/app.py similarity index 100% rename from shiny/examples/input_radio_buttons/app.py rename to shiny/api-examples/input_radio_buttons/app.py diff --git a/shiny/examples/input_select/app.py b/shiny/api-examples/input_select/app.py similarity index 100% rename from shiny/examples/input_select/app.py rename to shiny/api-examples/input_select/app.py diff --git a/shiny/examples/input_selectize/app.py b/shiny/api-examples/input_selectize/app.py similarity index 100% rename from shiny/examples/input_selectize/app.py rename to shiny/api-examples/input_selectize/app.py diff --git a/shiny/examples/input_slider/app.py b/shiny/api-examples/input_slider/app.py similarity index 100% rename from shiny/examples/input_slider/app.py rename to shiny/api-examples/input_slider/app.py diff --git a/shiny/examples/input_switch/app.py b/shiny/api-examples/input_switch/app.py similarity index 100% rename from shiny/examples/input_switch/app.py rename to shiny/api-examples/input_switch/app.py diff --git a/shiny/examples/input_text/app.py b/shiny/api-examples/input_text/app.py similarity index 100% rename from shiny/examples/input_text/app.py rename to shiny/api-examples/input_text/app.py diff --git a/shiny/examples/input_text_area/app.py b/shiny/api-examples/input_text_area/app.py similarity index 100% rename from shiny/examples/input_text_area/app.py rename to shiny/api-examples/input_text_area/app.py diff --git a/shiny/examples/insert_ui/app.py b/shiny/api-examples/insert_ui/app.py similarity index 100% rename from shiny/examples/insert_ui/app.py rename to shiny/api-examples/insert_ui/app.py diff --git a/shiny/examples/invalidate_later/app.py b/shiny/api-examples/invalidate_later/app.py similarity index 100% rename from shiny/examples/invalidate_later/app.py rename to shiny/api-examples/invalidate_later/app.py diff --git a/shiny/examples/isolate/app.py b/shiny/api-examples/isolate/app.py similarity index 100% rename from shiny/examples/isolate/app.py rename to shiny/api-examples/isolate/app.py diff --git a/shiny/examples/layout_sidebar/app.py b/shiny/api-examples/layout_sidebar/app.py similarity index 100% rename from shiny/examples/layout_sidebar/app.py rename to shiny/api-examples/layout_sidebar/app.py diff --git a/shiny/examples/markdown/app.py b/shiny/api-examples/markdown/app.py similarity index 100% rename from shiny/examples/markdown/app.py rename to shiny/api-examples/markdown/app.py diff --git a/shiny/examples/modal/app.py b/shiny/api-examples/modal/app.py similarity index 100% rename from shiny/examples/modal/app.py rename to shiny/api-examples/modal/app.py diff --git a/shiny/examples/nav/app.py b/shiny/api-examples/nav/app.py similarity index 100% rename from shiny/examples/nav/app.py rename to shiny/api-examples/nav/app.py diff --git a/shiny/examples/navset_hidden/app.py b/shiny/api-examples/navset_hidden/app.py similarity index 100% rename from shiny/examples/navset_hidden/app.py rename to shiny/api-examples/navset_hidden/app.py diff --git a/shiny/examples/notification_show/app.py b/shiny/api-examples/notification_show/app.py similarity index 100% rename from shiny/examples/notification_show/app.py rename to shiny/api-examples/notification_show/app.py diff --git a/shiny/examples/on_ended/app.py b/shiny/api-examples/on_ended/app.py similarity index 100% rename from shiny/examples/on_ended/app.py rename to shiny/api-examples/on_ended/app.py diff --git a/shiny/examples/on_flush/app.py b/shiny/api-examples/on_flush/app.py similarity index 100% rename from shiny/examples/on_flush/app.py rename to shiny/api-examples/on_flush/app.py diff --git a/shiny/examples/on_flushed/app.py b/shiny/api-examples/on_flushed/app.py similarity index 100% rename from shiny/examples/on_flushed/app.py rename to shiny/api-examples/on_flushed/app.py diff --git a/shiny/examples/output_image/app.py b/shiny/api-examples/output_image/app.py similarity index 100% rename from shiny/examples/output_image/app.py rename to shiny/api-examples/output_image/app.py diff --git a/shiny/examples/output_image/posit-logo.png b/shiny/api-examples/output_image/posit-logo.png similarity index 100% rename from shiny/examples/output_image/posit-logo.png rename to shiny/api-examples/output_image/posit-logo.png diff --git a/shiny/examples/output_plot/app.py b/shiny/api-examples/output_plot/app.py similarity index 100% rename from shiny/examples/output_plot/app.py rename to shiny/api-examples/output_plot/app.py diff --git a/shiny/examples/output_table/app.py b/shiny/api-examples/output_table/app.py similarity index 100% rename from shiny/examples/output_table/app.py rename to shiny/api-examples/output_table/app.py diff --git a/shiny/examples/output_table/mtcars.csv b/shiny/api-examples/output_table/mtcars.csv similarity index 100% rename from shiny/examples/output_table/mtcars.csv rename to shiny/api-examples/output_table/mtcars.csv diff --git a/shiny/api-examples/output_text/app.py b/shiny/api-examples/output_text/app.py new file mode 100644 index 000000000..f41594267 --- /dev/null +++ b/shiny/api-examples/output_text/app.py @@ -0,0 +1,41 @@ +from shiny import App, Inputs, Outputs, Session, render, ui + +app_ui = ui.page_fluid( + ui.input_text("txt", "Enter the text to display below:", "delete me"), + ui.row( + ui.column(6, ui.code("ui.output_text()"), ui.output_text("text")), + ui.column( + 6, + ui.code("ui.output_text_verbatim(placeholder=True)"), + ui.output_text_verbatim("verb", placeholder=True), + ), + ), + ui.row( + ui.column(6), + ui.column( + 6, + ui.code("ui.output_text_verbatim(placeholder=False)"), + ui.output_text_verbatim("verb_no_placeholder", placeholder=False), + ), + ), +) + + +def server(input: Inputs, output: Outputs, session: Session): + @output + @render.text + def text(): + return input.txt() + + @output + @render.text + def verb(): + return input.txt() + + @output + @render.text + def verb_no_placeholder(): + return input.txt() + + +app = App(app_ui, server) diff --git a/shiny/examples/output_ui/app.py b/shiny/api-examples/output_ui/app.py similarity index 100% rename from shiny/examples/output_ui/app.py rename to shiny/api-examples/output_ui/app.py diff --git a/shiny/examples/page_fixed/app.py b/shiny/api-examples/page_fixed/app.py similarity index 100% rename from shiny/examples/page_fixed/app.py rename to shiny/api-examples/page_fixed/app.py diff --git a/shiny/examples/page_fluid/app.py b/shiny/api-examples/page_fluid/app.py similarity index 100% rename from shiny/examples/page_fluid/app.py rename to shiny/api-examples/page_fluid/app.py diff --git a/shiny/examples/panel_absolute/app.py b/shiny/api-examples/panel_absolute/app.py similarity index 100% rename from shiny/examples/panel_absolute/app.py rename to shiny/api-examples/panel_absolute/app.py diff --git a/shiny/examples/panel_conditional/app.py b/shiny/api-examples/panel_conditional/app.py similarity index 100% rename from shiny/examples/panel_conditional/app.py rename to shiny/api-examples/panel_conditional/app.py diff --git a/shiny/examples/panel_title/app.py b/shiny/api-examples/panel_title/app.py similarity index 100% rename from shiny/examples/panel_title/app.py rename to shiny/api-examples/panel_title/app.py diff --git a/shiny/examples/poll/app.py b/shiny/api-examples/poll/app.py similarity index 100% rename from shiny/examples/poll/app.py rename to shiny/api-examples/poll/app.py diff --git a/shiny/examples/remove_ui/app.py b/shiny/api-examples/remove_ui/app.py similarity index 100% rename from shiny/examples/remove_ui/app.py rename to shiny/api-examples/remove_ui/app.py diff --git a/shiny/examples/render_image/app.py b/shiny/api-examples/render_image/app.py similarity index 100% rename from shiny/examples/render_image/app.py rename to shiny/api-examples/render_image/app.py diff --git a/shiny/examples/req/app.py b/shiny/api-examples/req/app.py similarity index 100% rename from shiny/examples/req/app.py rename to shiny/api-examples/req/app.py diff --git a/shiny/examples/row/app.py b/shiny/api-examples/row/app.py similarity index 100% rename from shiny/examples/row/app.py rename to shiny/api-examples/row/app.py diff --git a/shiny/examples/send_custom_message/app.py b/shiny/api-examples/send_custom_message/app.py similarity index 100% rename from shiny/examples/send_custom_message/app.py rename to shiny/api-examples/send_custom_message/app.py diff --git a/shiny/examples/template/app.py b/shiny/api-examples/template/app.py similarity index 100% rename from shiny/examples/template/app.py rename to shiny/api-examples/template/app.py diff --git a/shiny/api-examples/todo_list/app.py b/shiny/api-examples/todo_list/app.py new file mode 100644 index 000000000..4640a06fa --- /dev/null +++ b/shiny/api-examples/todo_list/app.py @@ -0,0 +1,113 @@ +import shinyswatch +from htmltools import css + +from shiny import App, module, reactive, render, ui + +app_ui = ui.page_fixed( + {"class": "my-5"}, + shinyswatch.theme.minty(), + ui.panel_title("Shiny TodoMVC"), + ui.layout_sidebar( + ui.panel_sidebar( + ui.input_text("todo_input_text", "", placeholder="Todo text"), + ui.input_action_button("add", "Add to-do"), + ), + ui.panel_main( + ui.output_text("cleared_tasks"), + ui.div(id="tasks", style="margin-top: 0.5em"), + ), + ), +) + + +def server(input, output, session): + finished_tasks = reactive.Value(0) + task_counter = reactive.Value(0) + + @output + @render.text + def cleared_tasks(): + return f"Finished tasks: {finished_tasks()}" + + @reactive.Effect + @reactive.event(input.add) + def add(): + counter = task_counter.get() + 1 + task_counter.set(counter) + id = "task_" + str(counter) + ui.insert_ui( + selector="#tasks", + where="beforeEnd", + ui=task_ui(id), + ) + + finish = task_server(id, text=input.todo_input_text()) + + # Defining a nested reactive effect like this might feel a bit funny but it's the + # correct pattern in this case. We are reacting to the `finish` + # event within the `add` closure, so nesting the reactive effects + # means that we don't have to worry about conflicting with + # finish events from other task elements. + @reactive.Effect + @reactive.event(finish) + def iterate_counter(): + finished_tasks.set(finished_tasks.get() + 1) + + ui.update_text("todo_input_text", value="") + + +# Modules to define the rows + + +@module.ui +def task_ui(): + return ui.output_ui("button_row") + + +@module.server +def task_server(input, output, session, text): + finished = reactive.Value(False) + + @output + @render.ui + def button_row(): + button = None + if finished(): + button = ui.input_action_button("clear", "Clear", class_="btn-warning") + else: + button = ui.input_action_button("finish", "Finish", class_="btn-default") + + return ui.row( + ui.column(4, button), + ui.column(8, text), + class_="mt-3 p-3 border align-items-center", + style=css(text_decoration="line-through" if finished() else None), + ) + + @reactive.Effect + @reactive.event(input.finish) + def finish_task(): + finished.set(True) + + @reactive.Effect + @reactive.event(input.clear) + def clear_task(): + ui.remove_ui(selector=f"div#{session.ns('button_row')}") + + # Since remove_ui only removes the HTML the reactive effects will be held + # in memory unless they're explicitly destroyed. This isn't a big + # deal because they're very small, but it's good to clean them up. + finish_task.destroy() + clear_task.destroy() + + # Returning the input.finish button to the parent scope allows us + # to react to it in the parent context to keep track of the number of + # completed tasks. + # + # This is a good pattern because it makes the module more general. + # The same module can be used by different applications which may + # do different things when the task is completed. + return input.finish + + +app = App(app_ui, server) diff --git a/shiny/api-examples/todo_list/requirements.txt b/shiny/api-examples/todo_list/requirements.txt new file mode 100644 index 000000000..69650978b --- /dev/null +++ b/shiny/api-examples/todo_list/requirements.txt @@ -0,0 +1 @@ +shinyswatch diff --git a/shiny/examples/update_action_button/app.py b/shiny/api-examples/update_action_button/app.py similarity index 100% rename from shiny/examples/update_action_button/app.py rename to shiny/api-examples/update_action_button/app.py diff --git a/shiny/examples/update_checkbox/app.py b/shiny/api-examples/update_checkbox/app.py similarity index 100% rename from shiny/examples/update_checkbox/app.py rename to shiny/api-examples/update_checkbox/app.py diff --git a/shiny/examples/update_checkbox_group/app.py b/shiny/api-examples/update_checkbox_group/app.py similarity index 100% rename from shiny/examples/update_checkbox_group/app.py rename to shiny/api-examples/update_checkbox_group/app.py diff --git a/shiny/examples/update_date/app.py b/shiny/api-examples/update_date/app.py similarity index 100% rename from shiny/examples/update_date/app.py rename to shiny/api-examples/update_date/app.py diff --git a/shiny/examples/update_date_range/app.py b/shiny/api-examples/update_date_range/app.py similarity index 100% rename from shiny/examples/update_date_range/app.py rename to shiny/api-examples/update_date_range/app.py diff --git a/shiny/examples/update_navs/app.py b/shiny/api-examples/update_navs/app.py similarity index 100% rename from shiny/examples/update_navs/app.py rename to shiny/api-examples/update_navs/app.py diff --git a/shiny/examples/update_numeric/app.py b/shiny/api-examples/update_numeric/app.py similarity index 100% rename from shiny/examples/update_numeric/app.py rename to shiny/api-examples/update_numeric/app.py diff --git a/shiny/examples/update_radio_buttons/app.py b/shiny/api-examples/update_radio_buttons/app.py similarity index 100% rename from shiny/examples/update_radio_buttons/app.py rename to shiny/api-examples/update_radio_buttons/app.py diff --git a/shiny/examples/update_select/app.py b/shiny/api-examples/update_select/app.py similarity index 100% rename from shiny/examples/update_select/app.py rename to shiny/api-examples/update_select/app.py diff --git a/shiny/examples/update_selectize/app.py b/shiny/api-examples/update_selectize/app.py similarity index 100% rename from shiny/examples/update_selectize/app.py rename to shiny/api-examples/update_selectize/app.py diff --git a/shiny/examples/update_slider/app.py b/shiny/api-examples/update_slider/app.py similarity index 100% rename from shiny/examples/update_slider/app.py rename to shiny/api-examples/update_slider/app.py diff --git a/shiny/examples/update_text/app.py b/shiny/api-examples/update_text/app.py similarity index 100% rename from shiny/examples/update_text/app.py rename to shiny/api-examples/update_text/app.py diff --git a/shiny/examples/www_dir/app.py b/shiny/api-examples/www_dir/app.py similarity index 100% rename from shiny/examples/www_dir/app.py rename to shiny/api-examples/www_dir/app.py diff --git a/shiny/examples/www_dir/www/css/more-styles.css b/shiny/api-examples/www_dir/www/css/more-styles.css similarity index 100% rename from shiny/examples/www_dir/www/css/more-styles.css rename to shiny/api-examples/www_dir/www/css/more-styles.css diff --git a/shiny/examples/www_dir/www/css/styles.css b/shiny/api-examples/www_dir/www/css/styles.css similarity index 100% rename from shiny/examples/www_dir/www/css/styles.css rename to shiny/api-examples/www_dir/www/css/styles.css diff --git a/shiny/examples/www_dir/www/js/changetext.js b/shiny/api-examples/www_dir/www/js/changetext.js similarity index 100% rename from shiny/examples/www_dir/www/js/changetext.js rename to shiny/api-examples/www_dir/www/js/changetext.js diff --git a/shiny/examples/output_text/app.py b/shiny/examples/output_text/app.py deleted file mode 100644 index 9dba76c20..000000000 --- a/shiny/examples/output_text/app.py +++ /dev/null @@ -1,33 +0,0 @@ -from shiny import App, Inputs, Outputs, Session, render, ui - -app_ui = ui.page_fluid( - ui.input_text("txt", "Enter the text to display below:"), - ui.row( - ui.column(6, ui.output_text("text")), - ui.column(6, ui.output_text_verbatim("verb", placeholder=True)), - ), - ui.row( - ui.column(6), - ui.column(6, ui.output_text_verbatim("verb_no_placeholder", placeholder=False)), - ), -) - - -def server(input: Inputs, output: Outputs, session: Session): - @output - @render.text - def text(): - return input.txt() - - @output - @render.text - def verb(): - return input.txt() - - @output - @render.text - def verb_no_placeholder(): - return input.txt() - - -app = App(app_ui, server) diff --git a/shiny/experimental/examples/accordion/app.py b/shiny/experimental/api-examples/accordion/app.py similarity index 100% rename from shiny/experimental/examples/accordion/app.py rename to shiny/experimental/api-examples/accordion/app.py diff --git a/shiny/experimental/examples/accordion_panel/app.py b/shiny/experimental/api-examples/accordion_panel/app.py similarity index 100% rename from shiny/experimental/examples/accordion_panel/app.py rename to shiny/experimental/api-examples/accordion_panel/app.py diff --git a/shiny/experimental/examples/accordion_panel_close/app.py b/shiny/experimental/api-examples/accordion_panel_close/app.py similarity index 100% rename from shiny/experimental/examples/accordion_panel_close/app.py rename to shiny/experimental/api-examples/accordion_panel_close/app.py diff --git a/shiny/experimental/examples/accordion_panel_insert/app.py b/shiny/experimental/api-examples/accordion_panel_insert/app.py similarity index 100% rename from shiny/experimental/examples/accordion_panel_insert/app.py rename to shiny/experimental/api-examples/accordion_panel_insert/app.py diff --git a/shiny/experimental/examples/accordion_panel_open/app.py b/shiny/experimental/api-examples/accordion_panel_open/app.py similarity index 100% rename from shiny/experimental/examples/accordion_panel_open/app.py rename to shiny/experimental/api-examples/accordion_panel_open/app.py diff --git a/shiny/experimental/examples/accordion_panel_remove/app.py b/shiny/experimental/api-examples/accordion_panel_remove/app.py similarity index 100% rename from shiny/experimental/examples/accordion_panel_remove/app.py rename to shiny/experimental/api-examples/accordion_panel_remove/app.py diff --git a/shiny/experimental/examples/accordion_panel_set/app.py b/shiny/experimental/api-examples/accordion_panel_set/app.py similarity index 100% rename from shiny/experimental/examples/accordion_panel_set/app.py rename to shiny/experimental/api-examples/accordion_panel_set/app.py diff --git a/shiny/experimental/examples/accordion_panel_update/app.py b/shiny/experimental/api-examples/accordion_panel_update/app.py similarity index 100% rename from shiny/experimental/examples/accordion_panel_update/app.py rename to shiny/experimental/api-examples/accordion_panel_update/app.py diff --git a/shiny/experimental/examples/as_fill_carrier/app.py b/shiny/experimental/api-examples/as_fill_carrier/app.py similarity index 100% rename from shiny/experimental/examples/as_fill_carrier/app.py rename to shiny/experimental/api-examples/as_fill_carrier/app.py diff --git a/shiny/experimental/examples/as_fill_item/app.py b/shiny/experimental/api-examples/as_fill_item/app.py similarity index 100% rename from shiny/experimental/examples/as_fill_item/app.py rename to shiny/experimental/api-examples/as_fill_item/app.py diff --git a/shiny/experimental/examples/as_fillable_container/app.py b/shiny/experimental/api-examples/as_fillable_container/app.py similarity index 100% rename from shiny/experimental/examples/as_fillable_container/app.py rename to shiny/experimental/api-examples/as_fillable_container/app.py diff --git a/shiny/experimental/examples/card/app.py b/shiny/experimental/api-examples/card/app.py similarity index 100% rename from shiny/experimental/examples/card/app.py rename to shiny/experimental/api-examples/card/app.py diff --git a/shiny/experimental/examples/card_body/app.py b/shiny/experimental/api-examples/card_body/app.py similarity index 100% rename from shiny/experimental/examples/card_body/app.py rename to shiny/experimental/api-examples/card_body/app.py diff --git a/shiny/experimental/examples/card_footer/app.py b/shiny/experimental/api-examples/card_footer/app.py similarity index 100% rename from shiny/experimental/examples/card_footer/app.py rename to shiny/experimental/api-examples/card_footer/app.py diff --git a/shiny/experimental/examples/card_header/app.py b/shiny/experimental/api-examples/card_header/app.py similarity index 100% rename from shiny/experimental/examples/card_header/app.py rename to shiny/experimental/api-examples/card_header/app.py diff --git a/shiny/experimental/examples/card_image/app.py b/shiny/experimental/api-examples/card_image/app.py similarity index 100% rename from shiny/experimental/examples/card_image/app.py rename to shiny/experimental/api-examples/card_image/app.py diff --git a/shiny/experimental/examples/card_title/app.py b/shiny/experimental/api-examples/card_title/app.py similarity index 100% rename from shiny/experimental/examples/card_title/app.py rename to shiny/experimental/api-examples/card_title/app.py diff --git a/shiny/experimental/examples/input_text_area/app.py b/shiny/experimental/api-examples/input_text_area/app.py similarity index 100% rename from shiny/experimental/examples/input_text_area/app.py rename to shiny/experimental/api-examples/input_text_area/app.py diff --git a/shiny/experimental/examples/layout_column_wrap/app.py b/shiny/experimental/api-examples/layout_column_wrap/app.py similarity index 100% rename from shiny/experimental/examples/layout_column_wrap/app.py rename to shiny/experimental/api-examples/layout_column_wrap/app.py diff --git a/shiny/experimental/examples/layout_sidebar/app.py b/shiny/experimental/api-examples/layout_sidebar/app.py similarity index 100% rename from shiny/experimental/examples/layout_sidebar/app.py rename to shiny/experimental/api-examples/layout_sidebar/app.py diff --git a/shiny/experimental/examples/page_sidebar/app.py b/shiny/experimental/api-examples/page_sidebar/app.py similarity index 100% rename from shiny/experimental/examples/page_sidebar/app.py rename to shiny/experimental/api-examples/page_sidebar/app.py diff --git a/shiny/experimental/examples/showcase_left_center/app.py b/shiny/experimental/api-examples/showcase_left_center/app.py similarity index 100% rename from shiny/experimental/examples/showcase_left_center/app.py rename to shiny/experimental/api-examples/showcase_left_center/app.py diff --git a/shiny/experimental/examples/showcase_top_right/app.py b/shiny/experimental/api-examples/showcase_top_right/app.py similarity index 100% rename from shiny/experimental/examples/showcase_top_right/app.py rename to shiny/experimental/api-examples/showcase_top_right/app.py diff --git a/shiny/experimental/examples/sidebar/app.py b/shiny/experimental/api-examples/sidebar/app.py similarity index 100% rename from shiny/experimental/examples/sidebar/app.py rename to shiny/experimental/api-examples/sidebar/app.py diff --git a/shiny/experimental/examples/sidebar_toggle/app.py b/shiny/experimental/api-examples/sidebar_toggle/app.py similarity index 100% rename from shiny/experimental/examples/sidebar_toggle/app.py rename to shiny/experimental/api-examples/sidebar_toggle/app.py diff --git a/shiny/experimental/api-examples/tooltip/app.py b/shiny/experimental/api-examples/tooltip/app.py new file mode 100644 index 000000000..5e4d8c845 --- /dev/null +++ b/shiny/experimental/api-examples/tooltip/app.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import shiny.experimental as x +from shiny import App, ui + +# https://icons.getbootstrap.com/icons/question-circle-fill/ +question_circle_fill = ui.HTML( + '' +) + +app_ui = ui.page_fluid( + x.ui.tooltip( + ui.input_action_button("btn", "A button", class_="mt-3"), + "A message", + id="btn_tooltip", + ), + ui.hr(), + x.ui.card( + x.ui.card_header( + x.ui.tooltip( + ui.span("Card title ", question_circle_fill), + "Additional info", + placement="right", + id="card_tooltip", + ), + ), + "Card body content...", + ), +) + + +app = App(app_ui, server=None) diff --git a/shiny/experimental/api-examples/tooltip_toggle/app.py b/shiny/experimental/api-examples/tooltip_toggle/app.py new file mode 100644 index 000000000..c868ca94b --- /dev/null +++ b/shiny/experimental/api-examples/tooltip_toggle/app.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import shiny.experimental as x +from shiny import App, Inputs, Outputs, Session, reactive, req, ui + +app_ui = ui.page_fluid( + ui.input_action_button("btn_show", "Show tooltip", class_="mt-3 me-3"), + ui.input_action_button("btn_close", "Close tooltip", class_="mt-3 me-3"), + ui.br(), + ui.input_action_button("btn_toggle", "Toggle tooltip", class_="mt-3 me-3"), + ui.br(), + ui.br(), + x.ui.tooltip( + ui.input_action_button("btn_w_tooltip", "A button w/ a tooltip", class_="mt-3"), + "A message", + id="tooltip_id", + ), +) + + +def server(input: Inputs, output: Outputs, session: Session): + @reactive.Effect + def _(): + req(input.btn_show()) + + x.ui.tooltip_toggle("tooltip_id", show=True) + + @reactive.Effect + def _(): + req(input.btn_close()) + + x.ui.tooltip_toggle("tooltip_id", show=False) + + @reactive.Effect + def _(): + req(input.btn_toggle()) + + x.ui.tooltip_toggle("tooltip_id") + + @reactive.Effect + def _(): + req(input.btn_w_tooltip()) + ui.notification_show("Button clicked!", duration=3, type="message") + + +app = App(app_ui, server=server) diff --git a/shiny/experimental/api-examples/update_tooltip/app.py b/shiny/experimental/api-examples/update_tooltip/app.py new file mode 100644 index 000000000..169ce5c55 --- /dev/null +++ b/shiny/experimental/api-examples/update_tooltip/app.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import shiny.experimental as x +from shiny import App, Inputs, Outputs, Session, reactive, req, ui + +app_ui = ui.page_fluid( + ui.input_action_button("btn_update", "Update tooltip phrase", class_="mt-3 me-3"), + ui.br(), + ui.br(), + x.ui.tooltip( + ui.input_action_button("btn_w_tooltip", "A button w/ a tooltip", class_="mt-3"), + "A message", + id="tooltip_id", + ), +) + + +def server(input: Inputs, output: Outputs, session: Session): + @reactive.Effect + def _(): + # Immediately display tooltip + x.ui.tooltip_toggle("tooltip_id", show=True) + + @reactive.Effect + def _(): + req(input.btn_update()) + + content = ( + "A " + " ".join(["NEW" for _ in range(input.btn_update())]) + " message" + ) + + x.ui.update_tooltip("tooltip_id", content) + x.ui.tooltip_toggle("tooltip_id", show=True) + + @reactive.Effect + def _(): + req(input.btn_w_tooltip()) + ui.notification_show("Button clicked!", duration=3, type="message") + + +app = App(app_ui, server=server) diff --git a/shiny/experimental/examples/value_box/app.py b/shiny/experimental/api-examples/value_box/app.py similarity index 100% rename from shiny/experimental/examples/value_box/app.py rename to shiny/experimental/api-examples/value_box/app.py diff --git a/shiny/experimental/e2e/navbar/app.py b/shiny/experimental/e2e/navbar/app.py index 856998b88..a9cf2d9e1 100644 --- a/shiny/experimental/e2e/navbar/app.py +++ b/shiny/experimental/e2e/navbar/app.py @@ -46,7 +46,6 @@ def nav_items(prefix: str) -> list[NavSetArg]: 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)." ), diff --git a/shiny/experimental/ui/__init__.py b/shiny/experimental/ui/__init__.py index 6b66be20d..54005f500 100644 --- a/shiny/experimental/ui/__init__.py +++ b/shiny/experimental/ui/__init__.py @@ -47,6 +47,7 @@ sidebar, sidebar_toggle, ) +from ._tooltip import tooltip, tooltip_toggle, update_tooltip from ._valuebox import showcase_left_center, showcase_top_right, value_box __all__ = ( @@ -84,6 +85,10 @@ "card_footer", # Layout "layout_column_wrap", + # Tooltip + "tooltip", + "tooltip_toggle", + "update_tooltip", # ValueBox "value_box", "showcase_left_center", diff --git a/shiny/experimental/ui/_card.py b/shiny/experimental/ui/_card.py index 9481e4552..1f7d5e964 100644 --- a/shiny/experimental/ui/_card.py +++ b/shiny/experimental/ui/_card.py @@ -23,6 +23,7 @@ from ._css_unit import CssUnit, as_css_padding, as_css_unit from ._fill import as_fill_carrier, as_fill_item, as_fillable_container from ._htmldeps import card_dependency +from ._tooltip import tooltip from ._utils import consolidate_attrs __all__ = ( @@ -112,6 +113,7 @@ def card( min_height=as_css_unit(min_height), ), "data-bslib-card-init": True, + "data-full-screen": "false" if full_screen else None, }, *children, attrs, @@ -134,14 +136,12 @@ def _card_js_init() -> Tag: def _full_screen_toggle() -> Tag: - return tags.span( - { - "class": "bslib-full-screen-enter badge rounded-pill bg-dark", - "data-bs-toggle": "tooltip", - "data-bs-placement": "bottom", - "title": "Expand", - }, - _full_screen_toggle_icon(), + return tooltip( + tags.span( + {"class": "bslib-full-screen-enter badge rounded-pill bg-dark"}, + _full_screen_toggle_icon(), + ), + "Expand", ) diff --git a/shiny/experimental/ui/_htmldeps.py b/shiny/experimental/ui/_htmldeps.py index c5b309118..9e9353882 100644 --- a/shiny/experimental/ui/_htmldeps.py +++ b/shiny/experimental/ui/_htmldeps.py @@ -1,65 +1,124 @@ from __future__ import annotations from pathlib import PurePath +from typing import Literal from htmltools import HTMLDependency from ... import __version__ as shiny_version +from ..._typing_extensions import NotRequired, TypedDict from ..._versions import bslib as bslib_version from ..._versions import htmltools as htmltools_version _x_www = PurePath(__file__).parent.parent / "www" _x_www_path = str(_x_www) -_x_components_path = str(_x_www / "bslib" / "components") -_x_fill_path = str(_x_www / "htmltools" / "fill") +_x_htmltools_path = _x_www / "htmltools" +_x_components_path = _x_www / "bslib" / "components" -def card_dependency() -> HTMLDependency: +def _htmltools_dep( + name: str, + script: bool = False, + stylesheet: bool = False, + all_files: bool = True, +) -> HTMLDependency: return HTMLDependency( - name="bslib-card", - version=bslib_version, + name=f"htmltools-{name}", + version=htmltools_version, source={ "package": "shiny", - "subdir": _x_components_path, + "subdir": str(_x_htmltools_path / "fill"), }, - script={"src": "card.min.js"}, + script={"src": f"{name}.min.js"} if script else None, + stylesheet={"href": f"{name}.css"} if stylesheet else None, + all_files=all_files, ) -def fill_dependency() -> HTMLDependency: - return HTMLDependency( - "htmltools-fill", - htmltools_version, - source={ - "package": "shiny", - "subdir": _x_fill_path, - }, - stylesheet={"href": "fill.css"}, - ) +class _ScriptItemDict(TypedDict): + src: str + type: NotRequired[Literal["module"]] -def sidebar_dependency() -> HTMLDependency: +def _bslib_component_dep( + name: str, + script: bool = False, + stylesheet: bool = False, + all_files: bool = True, + script_is_module: bool = False, +) -> HTMLDependency: + script_val: _ScriptItemDict | None = None + if script: + script_val = {"src": f"{name}.min.js"} + if script_is_module: + script_val["type"] = "module" + return HTMLDependency( - "bslib-sidebar", - bslib_version, + name=f"bslib-{name}", + version=bslib_version, source={ "package": "shiny", - "subdir": _x_components_path, + "subdir": str(_x_components_path / name), }, - script={"src": "sidebar.min.js"}, + script=script_val, # type: ignore # https://github.com/rstudio/py-htmltools/issues/59 + stylesheet={"href": f"{name}.css"} if stylesheet else None, + all_files=all_files, ) +# -- htmltools --------------------- + + +def fill_dependency() -> HTMLDependency: + return _htmltools_dep("fill", stylesheet=True) + + +# -- bslib ------------------------- + + def accordion_dependency() -> HTMLDependency: - return HTMLDependency( - "bslib-accordion", - version=bslib_version, - source={ - "package": "shiny", - "subdir": _x_components_path, - }, - script={"src": "accordion.min.js"}, - ) + return _bslib_component_dep("accordion", script=True, stylesheet=True) + + +def card_dependency() -> HTMLDependency: + return _bslib_component_dep("card", script=True, stylesheet=True) + + +def grid_dependency() -> HTMLDependency: + return _bslib_component_dep("grid", stylesheet=True) + + +# Coped to `ui._x._htmldeps.py` +# def nav_spacer_dependency() -> HTMLDependency: +# return _bslib_component_dep("nav_spacer", stylesheet=True) + + +def page_fillable_dependency() -> HTMLDependency: + return _bslib_component_dep("page_fillable", stylesheet=True) + + +# # Not used! +# def page_navbar_dependency() -> HTMLDependency: +# return _bslib_component_dep("page_navbar", stylesheet=True) + + +def page_sidebar_dependency() -> HTMLDependency: + return _bslib_component_dep("page_sidebar", stylesheet=True) + + +def sidebar_dependency() -> HTMLDependency: + return _bslib_component_dep("sidebar", script=True, stylesheet=True) + + +def value_box_dependency() -> HTMLDependency: + return _bslib_component_dep("value_box", stylesheet=True) + + +def web_component_dependency() -> HTMLDependency: + return _bslib_component_dep("webComponents", script=True, script_is_module=True) + + +# -- Experimental ------------------ def autoresize_dependency(): diff --git a/shiny/experimental/ui/_layout.py b/shiny/experimental/ui/_layout.py index 7c5c2dfb7..9f878ba01 100644 --- a/shiny/experimental/ui/_layout.py +++ b/shiny/experimental/ui/_layout.py @@ -6,6 +6,7 @@ from ._css_unit import CssUnit, as_css_unit from ._fill import as_fill_item, as_fillable_container +from ._htmldeps import grid_dependency from ._utils import consolidate_attrs, is_01_scalar @@ -119,6 +120,7 @@ def layout_column_wrap( }, attrs, *upgraded_children, + grid_dependency(), ) if fill: tag = as_fill_item(tag) diff --git a/shiny/experimental/ui/_navs.py b/shiny/experimental/ui/_navs.py index 38c487b68..5f9f169cd 100644 --- a/shiny/experimental/ui/_navs.py +++ b/shiny/experimental/ui/_navs.py @@ -160,7 +160,7 @@ def layout(self, nav: Tag, content: Tag) -> Tag: content_val = navset_card_body(content, sidebar=self.sidebar) if self.placement == "below": - # TODO-barret; have carson double check this change + # TODO-carson; have carson double check this change return card( card_header(self.header) if self.header else None, content_val, @@ -170,7 +170,7 @@ def layout(self, nav: Tag, content: Tag) -> Tag: card_footer(nav), ) else: - # TODO-barret; have carson double check this change + # TODO-carson; have carson double check this change return card( card_header(nav), card_body(self.header, fill=False, fillable=False) @@ -520,9 +520,7 @@ def navset_bar( 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) + ``True`` to automatically collapse the navigation elements into an expandable menu on mobile devices or narrow window widths. fluid ``True`` to use fluid layout; ``False`` to use fixed layout. diff --git a/shiny/experimental/ui/_page.py b/shiny/experimental/ui/_page.py index d125f1619..c786896d8 100644 --- a/shiny/experimental/ui/_page.py +++ b/shiny/experimental/ui/_page.py @@ -18,6 +18,7 @@ from ...ui._utils import get_window_title from ._css_unit import CssUnit, as_css_padding, as_css_unit from ._fill import as_fillable_container +from ._htmldeps import page_fillable_dependency, page_sidebar_dependency from ._navs import navset_bar from ._sidebar import Sidebar, layout_sidebar from ._utils import consolidate_attrs @@ -68,17 +69,20 @@ def page_sidebar( if isinstance(title, str): title = tags.h1(title, class_="bslib-page-title") + attrs, children = consolidate_attrs(*args, **kwargs) + return page_fillable( title, layout_sidebar( sidebar, - *args, + *children, + attrs, fillable=fillable, border=False, border_radius=False, - **kwargs, ), get_window_title(title, window_title=window_title), + page_sidebar_dependency(), padding=0, gap=0, lang=lang, @@ -102,7 +106,7 @@ def page_navbar( header: Optional[TagChild] = None, footer: Optional[TagChild] = None, bg: Optional[str] = None, - inverse: bool = False, + inverse: bool = True, collapsible: bool = True, fluid: bool = True, window_title: str | MISSING_TYPE = MISSING, @@ -147,9 +151,7 @@ def page_navbar( 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) + ``True`` to automatically collapse the elements into an expandable menu on mobile devices or narrow window widths. fluid ``True`` to use fluid layout; ``False`` to use fixed layout. window_title @@ -284,6 +286,7 @@ def page_fillable( *children, ) ), + page_fillable_dependency(), title=title, lang=lang, ) diff --git a/shiny/experimental/ui/_tooltip.py b/shiny/experimental/ui/_tooltip.py new file mode 100644 index 000000000..12532bc9b --- /dev/null +++ b/shiny/experimental/ui/_tooltip.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +import json +from typing import Literal, Optional + +from htmltools import Tag, TagAttrs, TagAttrValue, TagChild, TagList, div + +from ... import Session +from ..._utils import drop_none +from ...session import require_active_session + +# from ._color import get_color_contrast +from ._utils import consolidate_attrs +from ._web_component import web_component + + +def tooltip( + trigger: TagChild, + *args: TagChild | TagAttrs, + id: Optional[str] = None, + placement: Literal["auto", "top", "right", "bottom", "left"] = "auto", + options: Optional[dict[str, object]] = None, + **kwargs: TagAttrValue, +) -> Tag: + """ + Add a tooltip to a UI element + + Display additional information when focusing (or hovering over) a UI element. + + Parameters + ---------- + trigger + A UI element (i.e., :class:`~htmltools.Tag`) to serve as the tooltips trigger. + It's good practice for this element to be a keyboard-focusable and interactive + element (e.g., :func:`~shiny.ui.input_action_button`, + :func:`~shiny.ui.input_action_link`, etc.) so that the tooltip is accessible to + keyboard and assistive technology users. + *args + Contents to the tooltip's body. Or tag attributes that are supplied to the + resolved :class:`~htmltools.Tag` object. + id + A character string. Required to re-actively respond to the visibility of the + tooltip (via the `input[id]` value) and/or update the visibility/contents of the + tooltip. + placement + The placement of the tooltip relative to its trigger. + options + A list of additional [Bootstrap + options](https://getbootstrap.com/docs/5.2/components/tooltips/#options). + + Details + ------- + + If `trigger` yields multiple HTML elements (e.g., a :class:`~htmltools.TagList` or + complex [`shinywidgets`](https://github.com/rstudio/py-shinywidgets) object), the + last HTML element is used as the trigger. If the `trigger` should contain all of + those elements, wrap the object in a :func:`~htmltools.div` or :func:`~htmltools.span`. + + See Also + -------- + + * [Bootstrap tooltips documentation](https://getbootstrap.com/docs/5.2/components/tooltips/) + """ + attrs, children = consolidate_attrs(*args, **kwargs) + + if len(children) == 0: + raise RuntimeError("At least one value must be provided to `*args: TagChild`") + + res = web_component( + "bslib-tooltip", + { + "id": id, + "placement": placement, + "options": json.dumps(options) if options else None, + }, + attrs, + # Use display:none instead of