diff --git a/docs/_quartodoc-core.yml b/docs/_quartodoc-core.yml index d31b7f989..52d4f4930 100644 --- a/docs/_quartodoc-core.yml +++ b/docs/_quartodoc-core.yml @@ -83,6 +83,7 @@ quartodoc: - ui.navset_card_underline - ui.navset_pill_list - ui.navset_hidden + - ui.navbar_options - title: UI panels desc: Visually group together a section of UI components. contents: diff --git a/docs/_quartodoc-express.yml b/docs/_quartodoc-express.yml index 2b1f8e50c..6086b6fa2 100644 --- a/docs/_quartodoc-express.yml +++ b/docs/_quartodoc-express.yml @@ -77,6 +77,7 @@ quartodoc: - express.ui.navset_underline - express.ui.navset_pill_list - express.ui.navset_hidden + - express.ui.navbar_options - title: Chat interface desc: Build a chatbot interface contents: diff --git a/shiny/api-examples/navbar_options/app-core.py b/shiny/api-examples/navbar_options/app-core.py new file mode 100644 index 000000000..1080bacff --- /dev/null +++ b/shiny/api-examples/navbar_options/app-core.py @@ -0,0 +1,36 @@ +from shiny import App, render, ui + +app_ui = ui.page_fluid( + ui.navset_bar( + ui.nav_panel("A", "Panel A content"), + ui.nav_panel("B", "Panel B content"), + ui.nav_panel("C", "Panel C content"), + ui.nav_menu( + "Other links", + ui.nav_panel("D", "Panel D content"), + "----", + "Description:", + ui.nav_control( + ui.a("Shiny", href="https://shiny.posit.co", target="_blank") + ), + ), + id="selected_navset_bar", + title="Navset Bar", + navbar_options=ui.navbar_options( + bg="#B73A85", + theme="dark", + underline=False, + ), + ), + ui.h5("Selected:"), + ui.output_code("selected"), +) + + +def server(input, output, session): + @render.code + def selected(): + return input.selected_navset_bar() + + +app = App(app_ui, server) diff --git a/shiny/api-examples/navbar_options/app-express.py b/shiny/api-examples/navbar_options/app-express.py new file mode 100644 index 000000000..46631c040 --- /dev/null +++ b/shiny/api-examples/navbar_options/app-express.py @@ -0,0 +1,34 @@ +from shiny.express import input, render, ui + +with ui.navset_bar( + title="Navset Bar", + id="selected_navset_bar", + navbar_options=ui.navbar_options( + bg="#B73A85", + theme="dark", + underline=False, + ), +): + with ui.nav_panel("A"): + "Panel A content" + + with ui.nav_panel("B"): + "Panel B content" + + with ui.nav_panel("C"): + "Panel C content" + + with ui.nav_menu("Other links"): + with ui.nav_panel("D"): + "Page D content" + + "----" + "Description:" + with ui.nav_control(): + ui.a("Shiny", href="https://shiny.posit.co", target="_blank") +ui.h5("Selected:") + + +@render.code +def _(): + return input.selected_navset_bar() diff --git a/shiny/express/ui/__init__.py b/shiny/express/ui/__init__.py index 2dd603287..686b3739c 100644 --- a/shiny/express/ui/__init__.py +++ b/shiny/express/ui/__init__.py @@ -108,6 +108,7 @@ notification_show, notification_remove, nav_spacer, + navbar_options, Progress, Theme, value_box_theme, @@ -282,6 +283,7 @@ "navset_pill_list", "navset_tab", "navset_underline", + "navbar_options", "value_box", "panel_well", "panel_conditional", diff --git a/shiny/express/ui/_cm_components.py b/shiny/express/ui/_cm_components.py index 9d5b5e517..727a5aba3 100644 --- a/shiny/express/ui/_cm_components.py +++ b/shiny/express/ui/_cm_components.py @@ -8,11 +8,19 @@ from ... import ui from ..._docstring import add_example, no_example -from ...types import MISSING, MISSING_TYPE +from ...types import DEPRECATED, MISSING, MISSING_TYPE, MaybeMissing from ...ui._accordion import AccordionPanel from ...ui._card import CardItem from ...ui._layout_columns import BreakpointsUser -from ...ui._navs import NavMenu, NavPanel, NavSet, NavSetBar, NavSetCard +from ...ui._navs import ( + NavbarOptions, + NavbarOptionsPositionT, + NavMenu, + NavPanel, + NavSet, + NavSetBar, + NavSetCard, +) from ...ui._sidebar import SidebarOpenSpec, SidebarOpenValue from ...ui.css import CssUnit from .._recall_context import RecallContextManager @@ -1067,17 +1075,16 @@ def navset_bar( fillable: bool | list[str] = True, gap: Optional[CssUnit] = None, padding: Optional[CssUnit | list[CssUnit]] = None, - position: Literal[ - "static-top", "fixed-top", "fixed-bottom", "sticky-top" - ] = "static-top", header: TagChild = None, footer: TagChild = None, - bg: Optional[str] = None, - # TODO: default to 'auto', like we have in R (parse color via webcolors?) - inverse: bool = False, - underline: bool = True, - collapsible: bool = True, + navbar_options: Optional[NavbarOptions] = None, fluid: bool = True, + # Deprecated ---- + position: MaybeMissing[NavbarOptionsPositionT] = DEPRECATED, + bg: MaybeMissing[str | None] = DEPRECATED, + inverse: MaybeMissing[bool] = DEPRECATED, + underline: MaybeMissing[bool] = DEPRECATED, + collapsible: MaybeMissing[bool] = DEPRECATED, ) -> RecallContextManager[NavSetBar]: """ Context manager for a set of nav items as a tabset inside a card container. @@ -1095,8 +1102,7 @@ def navset_bar( Choose a particular nav item to select by default value (should match it's ``value``). sidebar - A :class:`~shiny.ui.Sidebar` component to display on every - :func:`~shiny.ui.nav_panel` page. + A :class:`~shiny.ui.Sidebar` component to display on every :func:`~shiny.ui.nav_panel` page. fillable Whether or not to allow fill items to grow/shrink to fit the browser window. If `True`, all `nav()` pages are fillable. A character vector, matching the value @@ -1104,7 +1110,7 @@ def navset_bar( provided, `fillable` makes the main content portion fillable. gap A CSS length unit defining the gap (i.e., spacing) between elements provided to - `*args`. + `*args`. This value is only used when the navbar is `fillable`. padding Padding to use for the body. This can be a numeric vector (which will be interpreted as pixels) or a character vector with valid CSS lengths. The length @@ -1113,26 +1119,45 @@ def navset_bar( the second value will be used for left and right. If three, then the first will be used for top, the second will be left and right, and the third will be bottom. If four, then the values will be interpreted as top, right, bottom, and - left respectively. + left respectively. This value is only used when the navbar is `fillable`. + header + UI to display above the selected content. + footer + UI to display below the selected content. + fluid + ``True`` to use fluid layout; ``False`` to use fixed layout. + navbar_options + Configure the appearance and behavior of the navbar using + :func:`~shiny.ui.navbar_options` to set properties like position, background + color, and more. + + `navbar_options` was added in v1.3.0 and replaces deprecated arguments + `position`, `bg`, `inverse`, `collapsible`, and `underline`. position + Deprecated in v1.3.0. Please use `navbar_options` instead; see + :func:`~shiny.ui.navbar_options` for details. + 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 + Deprecated in v1.3.0. Please use `navbar_options` instead; see + :func:`~shiny.ui.navbar_options` for details. + Background color of the navbar (a CSS color). inverse + Deprecated in v1.3.0. Please use `navbar_options` instead; see + :func:`~shiny.ui.navbar_options` for details. + Either ``True`` for a light text color or ``False`` for a dark text color. collapsible - ``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. + Deprecated in v1.3.0. Please use `navbar_options` instead; see + :func:`~shiny.ui.navbar_options` for details. + + ``True`` to automatically collapse the elements into an expandable menu on + mobile devices or narrow window widths. """ return RecallContextManager( ui.navset_bar, @@ -1144,14 +1169,16 @@ def navset_bar( fillable=fillable, gap=gap, padding=padding, - position=position, header=header, footer=footer, + fluid=fluid, + navbar_options=navbar_options, + # Deprecated -- v1.3.0 2025-01 ---- + position=position, bg=bg, inverse=inverse, underline=underline, collapsible=collapsible, - fluid=fluid, ), ) diff --git a/shiny/types.py b/shiny/types.py index 562ec5d4b..c03327853 100644 --- a/shiny/types.py +++ b/shiny/types.py @@ -31,11 +31,13 @@ from htmltools import TagChild from ._docstring import add_example -from ._typing_extensions import NotRequired, TypedDict +from ._typing_extensions import NotRequired, TypedDict, TypeIs if TYPE_CHECKING: from matplotlib.figure import Figure +T = TypeVar("T") + # Sentinel value - indicates a missing value in a function call. class MISSING_TYPE: @@ -43,12 +45,16 @@ class MISSING_TYPE: MISSING: MISSING_TYPE = MISSING_TYPE() +DEPRECATED: MISSING_TYPE = MISSING_TYPE() # A MISSING that communicates deprecation +MaybeMissing = Union[T, MISSING_TYPE] - -T = TypeVar("T") ListOrTuple = Union[List[T], Tuple[T, ...]] +def is_missing(x: Any) -> TypeIs[MISSING_TYPE]: + return x is MISSING or isinstance(x, MISSING_TYPE) + + # Information about a single file, with a structure like: # {'name': 'mtcars.csv', 'size': 1303, 'type': 'text/csv', 'datapath: '/...../mtcars.csv'} # The incoming data doesn't include 'datapath'; that field is added by the diff --git a/shiny/ui/__init__.py b/shiny/ui/__init__.py index b674cfeb3..85475e76b 100644 --- a/shiny/ui/__init__.py +++ b/shiny/ui/__init__.py @@ -115,6 +115,7 @@ nav_menu, nav_panel, nav_spacer, + navbar_options, navset_bar, navset_card_pill, navset_card_tab, @@ -291,6 +292,7 @@ "navset_pill_list", "navset_hidden", "navset_bar", + "navbar_options", # _notification "notification_show", "notification_remove", diff --git a/shiny/ui/_navs.py b/shiny/ui/_navs.py index a87d8bb9d..46becf30a 100644 --- a/shiny/ui/_navs.py +++ b/shiny/ui/_navs.py @@ -3,13 +3,14 @@ import collections.abc import copy import re -from typing import Any, Literal, Optional, Sequence, cast +from typing import Any, Generic, Literal, Optional, Sequence, TypeVar, cast from htmltools import ( HTML, MetadataNode, Tag, TagAttrs, + TagAttrValue, TagChild, TagList, css, @@ -17,10 +18,11 @@ tags, ) +from .._deprecated import warn_deprecated from .._docstring import add_example from .._namespaces import resolve_id_or_none from .._utils import private_random_int -from ..types import NavSetArg +from ..types import DEPRECATED, MISSING, MaybeMissing, NavSetArg, is_missing from ._bootstrap import column, row from ._card import CardItem, WrapperCallable, card, card_body, card_footer, card_header from ._html_deps_shinyverse import components_dependencies @@ -42,8 +44,11 @@ "navset_pill_list", "navset_hidden", "navset_bar", + "navbar_options", ) +T = TypeVar("T") + # ----------------------------------------------------------------------------- # Navigation items @@ -985,18 +990,209 @@ def navset_pill_list( ) +class Default(Generic[T]): + def __init__(self, value: T): + self._default = value + + +NavbarOptionsPositionT = Literal[ + "static-top", "fixed-top", "fixed-bottom", "sticky-top" +] +NavbarOptionsThemeT = Literal["auto", "light", "dark"] + + +class NavbarOptions: + position: NavbarOptionsPositionT + bg: Optional[str] + theme: NavbarOptionsThemeT + underline: bool + collapsible: bool + attrs: dict[str, Any] + _is_default: dict[str, bool] + + def __init__( + self, + *, + position: MaybeMissing[NavbarOptionsPositionT] = MISSING, + bg: MaybeMissing[str | None] = MISSING, + theme: MaybeMissing[NavbarOptionsThemeT] = MISSING, + underline: MaybeMissing[bool] = MISSING, + collapsible: MaybeMissing[bool] = MISSING, + **attrs: TagAttrValue, + ): + self._is_default = {} + + self.position = self._maybe_default("position", position, default="static-top") + self.bg = self._maybe_default("bg", bg, default=None) + self.theme = self._maybe_default("theme", theme, default="auto") + self.underline = self._maybe_default("underline", underline, default=True) + self.collapsible = self._maybe_default("collapsible", collapsible, default=True) + + if "inverse" in attrs: + warn_deprecated( + "`navbar_options()` does not support `inverse`, please use `theme` instead." + ) + del attrs["inverse"] + + self.attrs = attrs + + def _maybe_default(self, name: str, value: Any, default: Any): + if is_missing(value): + self._is_default[name] = True + return default + return value + + def __eq__(self, other: Any): + if not isinstance(other, NavbarOptions): + return False + + return ( + self.position == other.position + and self.bg == other.bg + and self.theme == other.theme + and self.underline == other.underline + and self.collapsible == other.collapsible + and self.attrs == other.attrs + ) + + def __repr__(self): + fields: list[str] = [] + for key, value in self.__dict__.items(): + if key == "_is_default": + continue + if not self._is_default.get(key, False): + if key == "attrs" and len(value) == 0: + continue + fields.append(f"{key}={value!r}") + + return f"navbar_options({', '.join(fields)})" + + +@add_example() +def navbar_options( + position: MaybeMissing[NavbarOptionsPositionT] = MISSING, + bg: MaybeMissing[str | None] = MISSING, + theme: MaybeMissing[NavbarOptionsThemeT] = MISSING, + underline: MaybeMissing[bool] = MISSING, + collapsible: MaybeMissing[bool] = MISSING, + **attrs: TagAttrValue, +) -> NavbarOptions: + """ + Configure the appearance and behavior of the navbar. + + Parameters + ----------- + 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;}")`) + bg + Background color of the navbar (a CSS color). + theme + The navbar theme: either `"dark"` for a light text color (on a **dark** + background) or `"light"` for a dark text color (on a **light** background). If + `"auto"` (the default) and `bg` is provided, the best contrast to `bg` is + chosen. + underline + If `True`, adds an underline effect to the navbar. + collapsible + If `True`, automatically collapses the elements into an expandable menu on + mobile devices or narrow window widths. + **attrs : dict + Additional HTML attributes to apply to the navbar container element. + + Returns: + -------- + NavbarOptions + A NavbarOptions object configured with the specified options. + """ + return NavbarOptions( + position=position, + bg=bg, + theme=theme, + underline=underline, + collapsible=collapsible, + **attrs, + ) + + +def navbar_options_resolve_deprecated( + options_user: Optional[NavbarOptions] = None, + position: MaybeMissing[NavbarOptionsPositionT] = DEPRECATED, + bg: MaybeMissing[str | None] = DEPRECATED, + inverse: MaybeMissing[bool] = DEPRECATED, + underline: MaybeMissing[bool] = DEPRECATED, + collapsible: MaybeMissing[bool] = DEPRECATED, + fn_caller: str = "navset_bar", +) -> NavbarOptions: + options_user = options_user if options_user is not None else navbar_options() + + options_old = { + "position": position, + "bg": bg, + "inverse": inverse, + "collapsible": collapsible, + "underline": underline, + } + options_old = {k: v for k, v in options_old.items() if not is_missing(v)} + + args_deprecated = list(options_old.keys()) + + if not args_deprecated: + return options_user + + args_deprecated = ", ".join([f"`{arg}`" for arg in args_deprecated]) + warn_deprecated( + "In shiny v1.3.0, the arguments of " + f"`{fn_caller}()` for navbar options (including {args_deprecated}) " + f"have been consolidated into a single `navbar_options` argument." + ) + + if "inverse" in options_old: + inverse_old = options_old["inverse"] + del options_old["inverse"] + + if not isinstance(inverse_old, bool): + raise ValueError(f"Invalid `inverse` value: {inverse}") + + options_old["theme"] = "dark" if inverse_old else "light" + + options_resolved = { + k: v + for k, v in vars(options_user).items() + if k != "_is_default" and not options_user._is_default.get(k, False) + } + + ignored: list[str] = [] + for opt in options_old: + if opt not in options_resolved: + options_resolved[opt] = options_old[opt] + elif options_old[opt] != options_resolved[opt]: + ignored.append("inverse" if opt == "theme" else opt) + + if ignored: + warn_deprecated( + f"`{', '.join(ignored)}` {'was' if len(ignored) == 1 else 'were'} provided twice: " + "once directly and once in `navbar_options`.\n" + "The deprecated direct option(s) will be ignored and the values from `navbar_options` will be used." + ) + + attrs = options_resolved.pop("attrs", {}) + + return navbar_options(**options_resolved, **attrs) + + class NavSetBar(NavSet): title: TagChild sidebar: Optional[Sidebar] fillable: bool | list[str] gap: Optional[CssUnit] padding: Optional[CssUnit | list[CssUnit]] - position: Literal["static-top", "fixed-top", "fixed-bottom", "sticky-top"] - bg: Optional[str] - inverse: bool - underline: bool - collapsible: bool fluid: bool + navbar_options: NavbarOptions + # Internal ---- _is_page_level: bool def __init__( @@ -1010,17 +1206,10 @@ def __init__( fillable: bool | list[str] = False, gap: Optional[CssUnit], padding: Optional[CssUnit | list[CssUnit]], - position: Literal[ - "static-top", "fixed-top", "fixed-bottom", "sticky-top" - ] = "static-top", + fluid: bool = True, header: TagChild = None, footer: TagChild = None, - bg: Optional[str] = None, - # TODO: default to 'auto', like we have in R (parse color via webcolors?) - inverse: bool = False, - underline: bool = True, - collapsible: bool = True, - fluid: bool = True, + navbar_options: Optional[NavbarOptions] = None, ) -> None: super().__init__( *args, @@ -1035,11 +1224,9 @@ def __init__( self.fillable = fillable self.gap = gap self.padding = padding - self.position = position - self.bg = bg - self.inverse = inverse - self.underline = underline - self.collapsible = collapsible + self.navbar_options = ( + navbar_options if navbar_options is not None else NavbarOptions() + ) self.fluid = fluid self._is_page_level = False @@ -1048,7 +1235,7 @@ def layout(self, nav: Tag, content: Tag) -> TagList: {"class": "container-fluid" if self.fluid else "container"}, tags.span({"class": "navbar-brand"}, self.title), ) - if self.collapsible: + if self.navbar_options.collapsible: collapse_id = "navbar-collapse-" + nav_random_int() nav_container.append( tags.button( @@ -1067,15 +1254,21 @@ def layout(self, nav: Tag, content: Tag) -> TagList: 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) + if self.navbar_options.position != "static-top": + nav_final.add_class(self.navbar_options.position) # bslib supports navbar-default/navbar-inverse (which is no longer # a thing in Bootstrap 5) in a way that's still useful, especially Bootswatch. - nav_final.add_class(f"navbar-{'inverse' if self.inverse else 'default'}") + nav_final.add_class( + "navbar-inverse" + if self.navbar_options.theme == "dark" + else "navbar-default" + ) - if self.bg: - nav_final.add_style(f"background-color: {self.bg} !important;") + if self.navbar_options.bg: + nav_final.add_style( + f"background-color: {self.navbar_options.bg} !important;" + ) content = _make_tabs_fillable( content, @@ -1177,17 +1370,16 @@ def navset_bar( fillable: bool | list[str] = True, gap: Optional[CssUnit] = None, padding: Optional[CssUnit | list[CssUnit]] = None, - position: Literal[ - "static-top", "fixed-top", "fixed-bottom", "sticky-top" - ] = "static-top", header: TagChild = None, footer: TagChild = None, - bg: Optional[str] = None, - # TODO: default to 'auto', like we have in R (parse color via webcolors?) - inverse: bool = False, - underline: bool = True, - collapsible: bool = True, + navbar_options: Optional[NavbarOptions] = None, fluid: bool = True, + # Deprecated -- v1.3.0 2025-01 ---- + position: MaybeMissing[NavbarOptionsPositionT] = DEPRECATED, + bg: MaybeMissing[str | None] = DEPRECATED, + inverse: MaybeMissing[bool] = DEPRECATED, + underline: MaybeMissing[bool] = DEPRECATED, + collapsible: MaybeMissing[bool] = DEPRECATED, ) -> NavSetBar: """ Render nav items as a navbar. @@ -1223,24 +1415,44 @@ def navset_bar( be used for top, the second will be left and right, and the third will be bottom. If four, then the values will be interpreted as top, right, bottom, and left respectively. This value is only used when the navbar is `fillable`. + header + UI to display above the selected content. + footer + UI to display below the selected content. + fluid + ``True`` to use fluid layout; ``False`` to use fixed layout. + navbar_options + Configure the appearance and behavior of the navbar using + :func:`~shiny.ui.navbar_options` to set properties like position, background + color, and more. + + `navbar_options` was added in v1.3.0 and replaces deprecated arguments + `position`, `bg`, `inverse`, `collapsible`, and `underline`. position + Deprecated in v1.3.0. Please use `navbar_options` instead; see + :func:`~shiny.ui.navbar_options` for details. + 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 + Deprecated in v1.3.0. Please use `navbar_options` instead; see + :func:`~shiny.ui.navbar_options` for details. + Background color of the navbar (a CSS color). inverse + Deprecated in v1.3.0. Please use `navbar_options` instead; see + :func:`~shiny.ui.navbar_options` for details. + Either ``True`` for a light text color or ``False`` for a dark text color. collapsible - ``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. + Deprecated in v1.3.0. Please use `navbar_options` instead; see + :func:`~shiny.ui.navbar_options` for details. + + ``True`` to automatically collapse the elements into an expandable menu on + mobile devices or narrow window widths. See Also -------- @@ -1271,8 +1483,18 @@ def navset_bar( else: new_args.append(cast(NavSetArg, arg)) + navbar_opts = navbar_options_resolve_deprecated( + fn_caller="navset_bar", + options_user=navbar_options or NavbarOptions(), + position=position, + bg=bg, + inverse=inverse, + underline=underline, + collapsible=collapsible, + ) + ul_class = "nav navbar-nav" - if underline: + if navbar_opts.underline: ul_class += " nav-underline" return NavSetBar( @@ -1285,14 +1507,10 @@ def navset_bar( gap=gap, padding=padding, title=title, - position=position, header=header, footer=footer, - bg=bg, - inverse=inverse, - underline=underline, - collapsible=collapsible, fluid=fluid, + navbar_options=navbar_opts, ) diff --git a/shiny/ui/_page.py b/shiny/ui/_page.py index cd5695f5a..d1d7e169f 100644 --- a/shiny/ui/_page.py +++ b/shiny/ui/_page.py @@ -31,12 +31,18 @@ from .._docstring import add_example, no_example from .._namespaces import resolve_id_or_none -from ..types import MISSING, MISSING_TYPE, NavSetArg +from ..types import DEPRECATED, MISSING, MISSING_TYPE, MaybeMissing, NavSetArg from ._bootstrap import panel_title from ._html_deps_external import Theme, ThemeProvider, shiny_page_theme_deps from ._html_deps_py_shiny import page_output_dependency from ._html_deps_shinyverse import components_dependencies -from ._navs import NavMenu, NavPanel, navset_bar +from ._navs import ( + NavbarOptions, + NavMenu, + NavPanel, + navbar_options_resolve_deprecated, + navset_bar, +) from ._sidebar import Sidebar, SidebarOpen, layout_sidebar from ._tag import consolidate_attrs from ._utils import get_window_title @@ -161,17 +167,21 @@ def page_navbar( fillable_mobile: bool = False, gap: Optional[CssUnit] = None, padding: Optional[CssUnit | list[CssUnit]] = None, - 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, - underline: bool = True, - collapsible: bool = True, + navbar_options: Optional[NavbarOptions] = None, fluid: bool = True, window_title: str | MISSING_TYPE = MISSING, lang: Optional[str] = None, theme: Optional[str | Path | Theme | ThemeProvider] = None, + # Deprecated -- v1.3.0 2025-01 ---- + position: MaybeMissing[ + Literal["static-top", "fixed-top", "fixed-bottom"] + ] = DEPRECATED, + bg: MaybeMissing[str | None] = DEPRECATED, + inverse: MaybeMissing[bool] = DEPRECATED, + underline: MaybeMissing[bool] = DEPRECATED, + collapsible: MaybeMissing[bool] = DEPRECATED, ) -> Tag: """ Create a page with a navbar and a title. @@ -208,24 +218,10 @@ def page_navbar( be used for top, the second will be left and right, and the third will be bottom. If four, then the values will be interpreted as top, right, bottom, and left respectively. This value is only used when the navbar is _fillable_. - 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 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 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`. @@ -244,6 +240,39 @@ def page_navbar( To modify the theme of an app without replacing the Bootstrap CSS entirely, use :func:`~shiny.ui.include_css` to add custom CSS. + fluid + ``True`` to use fluid layout; ``False`` to use fixed layout. + navbar_options + Configure the appearance and behavior of the navbar using + :func:`~shiny.ui.navbar_options` to set properties like position, background + color, and more. + + `navbar_options` was added in v1.3.0 and replaces deprecated arguments + `position`, `bg`, `inverse`, `collapsible`, and `underline`. + position + Deprecated in v1.3.0. Please use `navbar_options` instead; see + :func:`~shiny.ui.navbar_options` for details. + + 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;}")``). + bg + Deprecated in v1.3.0. Please use `navbar_options` instead; see + :func:`~shiny.ui.navbar_options` for details. + + Background color of the navbar (a CSS color). + inverse + Deprecated in v1.3.0. Please use `navbar_options` instead; see + :func:`~shiny.ui.navbar_options` for details. + + Either ``True`` for a light text color or ``False`` for a dark text color. + collapsible + Deprecated in v1.3.0. Please use `navbar_options` instead; see + :func:`~shiny.ui.navbar_options` for details. + + ``True`` to automatically collapse the elements into an expandable menu on mobile devices or narrow window widths. Returns ------- @@ -276,6 +305,16 @@ def page_navbar( tagAttrs: TagAttrs = {"class": pageClass} + navbar_options = navbar_options_resolve_deprecated( + fn_caller="page_navbar", + options_user=navbar_options, + position=position, + bg=bg, + inverse=inverse, + underline=underline, + collapsible=collapsible, + ) + navbar = navset_bar( *args, title=title, @@ -285,13 +324,9 @@ def page_navbar( fillable=fillable, gap=gap, padding=padding, - position=position, + navbar_options=navbar_options, header=header, footer=footer, - bg=bg, - inverse=inverse, - underline=underline, - collapsible=collapsible, fluid=fluid, ) # This is a page-level navbar, so opt into page-level layouts (in particular for diff --git a/tests/pytest/test_navs.py b/tests/pytest/test_navs.py index 1ac453360..b42224f12 100644 --- a/tests/pytest/test_navs.py +++ b/tests/pytest/test_navs.py @@ -5,10 +5,17 @@ import textwrap from typing import Generator +import pytest from htmltools import TagList from shiny import ui +from shiny._deprecated import ShinyDeprecationWarning from shiny._utils import private_seed +from shiny.ui._navs import ( + NavbarOptions, + navbar_options, + navbar_options_resolve_deprecated, +) # Fix the randomness of these functions to make the tests deterministic @@ -176,3 +183,119 @@ def test_navset_bar_markup(): Page footer """ ) + + +# navbar_options() ------------------------------------------------------------------- + + +def test_navbar_options_no_deprecated_arguments(): + options_user = navbar_options() + result = navbar_options_resolve_deprecated(options_user) + assert isinstance(result, NavbarOptions) + assert result == navbar_options() + + +def test_navbar_options_deprecated_arguments(): + options_user = navbar_options() + assert options_user._is_default.get("position", False) + assert options_user._is_default.get("underline", False) + + with pytest.warns(ShinyDeprecationWarning, match="`position`, `underline`"): + result = navbar_options_resolve_deprecated( + options_user, + position="static-top", + underline=True, + ) + + assert isinstance(result, NavbarOptions) + assert result == navbar_options() + + +def test_navbar_options_inverse_true(): + options_user = navbar_options() + with pytest.warns(ShinyDeprecationWarning, match="`inverse`"): + result = navbar_options_resolve_deprecated(options_user, inverse=True) + assert isinstance(result, NavbarOptions) + assert result.theme == "dark" + + +def test_navbar_options_inverse_false(): + options_user = navbar_options() + with pytest.warns(ShinyDeprecationWarning, match="`inverse`"): + result = navbar_options_resolve_deprecated(options_user, inverse=False) + assert isinstance(result, NavbarOptions) + assert result.theme == "light" + + +def test_navbar_options_inverse_invalid(): + options_user = navbar_options() + with pytest.warns(ShinyDeprecationWarning, match="`inverse`"): + with pytest.raises(ValueError, match="Invalid `inverse` value: 42"): + navbar_options_resolve_deprecated(options_user, inverse=42) # type: ignore + + +def test_navbar_options_conflicting_options(): + options_user = navbar_options(position="fixed-top") + with pytest.warns(ShinyDeprecationWarning, match="`position`"): + with pytest.warns( + ShinyDeprecationWarning, match="`position` was provided twice" + ): + result = navbar_options_resolve_deprecated( + options_user, position="fixed-bottom" + ) + assert isinstance(result, NavbarOptions) + assert result.position == "fixed-top" + + +def test_navbar_options_attribs_in_options_user(): + options_user = navbar_options(class_="my-navbar") + result = navbar_options_resolve_deprecated(options_user) + assert isinstance(result, NavbarOptions) + assert result.attrs == {"class_": "my-navbar"} + + +def test_navbar_options_mixed_options(): + options_user = navbar_options(position="fixed-bottom", bg="light") + assert not options_user._is_default.get("position", False) + assert not options_user._is_default.get("bg", False) + + with pytest.warns(ShinyDeprecationWarning, match="`bg`"): + with pytest.warns(ShinyDeprecationWarning, match="`bg` was provided twice"): + result = navbar_options_resolve_deprecated(options_user, bg="dark") + + assert isinstance(result, NavbarOptions) + assert result.position == "fixed-bottom" + assert result.bg == "light" + + +def test_navbar_options_all_deprecated_arguments(): + options_user = navbar_options() + with pytest.warns( + ShinyDeprecationWarning, + match="arguments of `navset_bar\\(\\)` for navbar options", + ): + result = navbar_options_resolve_deprecated( + options_user, + position="static-top", + bg="dark", + inverse=True, + collapsible=True, + underline=True, + ) + assert isinstance(result, NavbarOptions) + assert result.theme == "dark" + + +def test_navbar_options_fn_caller_custom(): + options_user = navbar_options() + with pytest.warns( + ShinyDeprecationWarning, + match="arguments of `custom_caller\\(\\)` for navbar options", + ): + result = navbar_options_resolve_deprecated( + options_user, + position="static-top", + fn_caller="custom_caller", + ) + assert isinstance(result, NavbarOptions) + assert result == navbar_options()