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