diff --git a/CHANGELOG.md b/CHANGELOG.md index f26466bd9..9723d9a7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [UNRELEASED] +### Breaking changes + +* The navbar-related style options of `ui.page_navbar()` and `ui.navset_bar()` have been consolidated into a single `navbar_options` argument that pairs with a new `ui.navbar_options()` helper. Using the direct `position`, `bg`, `inverse`, `collapsible`, and `underline` arguments will continue to work with a deprecation message. + + Related to this change, `ui.navset_bar()` now defaults to using `underline=True` so that it uses the same set of default `ui.navbar_options()` as the page variant. In `ui.navbar_options()`, `inverse` is replaced by `theme`, which takes values `"light"` (dark text on a **light** background), `"dark"` (light text on a **dark** background), or `"auto"` (follow page settings). + ### New features * Added a new `ui.MarkdownStream()` component for performantly streaming in chunks of markdown/html strings into the UI. This component is primarily useful for text-based generative AI where responses are received incrementally. (#1782) diff --git a/docs/_quartodoc-core.yml b/docs/_quartodoc-core.yml index e26759c5e..ed225f878 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 7813559ca..28d976969 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 6d9a2d47f..2edfa3c47 100644 --- a/shiny/express/ui/__init__.py +++ b/shiny/express/ui/__init__.py @@ -107,6 +107,7 @@ notification_show, notification_remove, nav_spacer, + navbar_options, Progress, Theme, value_box_theme, @@ -288,6 +289,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..b4b7f8e98 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 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, + NavbarOptionsPositionType, + 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: NavbarOptionsPositionType | MISSING_TYPE = DEPRECATED, + bg: str | None | MISSING_TYPE = DEPRECATED, + inverse: bool | MISSING_TYPE = DEPRECATED, + underline: bool | MISSING_TYPE = DEPRECATED, + collapsible: bool | MISSING_TYPE = 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..64e40f4f6 100644 --- a/shiny/types.py +++ b/shiny/types.py @@ -36,6 +36,8 @@ 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,9 +45,8 @@ class MISSING_TYPE: MISSING: MISSING_TYPE = MISSING_TYPE() +DEPRECATED: MISSING_TYPE = MISSING_TYPE() # A MISSING that communicates deprecation - -T = TypeVar("T") ListOrTuple = Union[List[T], Tuple[T, ...]] diff --git a/shiny/ui/__init__.py b/shiny/ui/__init__.py index 89a6217f3..9ca59cfa9 100644 --- a/shiny/ui/__init__.py +++ b/shiny/ui/__init__.py @@ -116,6 +116,7 @@ nav_menu, nav_panel, nav_spacer, + navbar_options, navset_bar, navset_card_pill, navset_card_tab, @@ -295,6 +296,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..b9a99d5c3 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, 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, MISSING_TYPE, NavSetArg 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,206 @@ def navset_pill_list( ) +NavbarOptionsPositionType = Literal[ + "static-top", "fixed-top", "fixed-bottom", "sticky-top" +] +NavbarOptionsThemeType = Literal["auto", "light", "dark"] + + +class NavbarOptions: + position: NavbarOptionsPositionType + bg: Optional[str] + theme: NavbarOptionsThemeType + underline: bool + collapsible: bool + attrs: dict[str, Any] + _is_default: dict[str, bool] + + def __init__( + self, + *, + position: NavbarOptionsPositionType | MISSING_TYPE = MISSING, + bg: str | None | MISSING_TYPE = MISSING, + theme: NavbarOptionsThemeType | MISSING_TYPE = MISSING, + underline: bool | MISSING_TYPE = MISSING, + collapsible: bool | MISSING_TYPE = 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 isinstance(value, MISSING_TYPE): + 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: NavbarOptionsPositionType | MISSING_TYPE = MISSING, + bg: str | None | MISSING_TYPE = MISSING, + theme: NavbarOptionsThemeType | MISSING_TYPE = MISSING, + underline: bool | MISSING_TYPE = MISSING, + collapsible: bool | MISSING_TYPE = 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: NavbarOptionsPositionType | MISSING_TYPE = DEPRECATED, + bg: str | None | MISSING_TYPE = DEPRECATED, + inverse: bool | MISSING_TYPE = DEPRECATED, + underline: bool | MISSING_TYPE = DEPRECATED, + collapsible: bool | MISSING_TYPE = 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 isinstance(v, MISSING_TYPE) + } + + 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 +1203,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 +1221,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 +1232,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 +1251,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 +1367,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: NavbarOptionsPositionType | MISSING_TYPE = DEPRECATED, + bg: str | None | MISSING_TYPE = DEPRECATED, + inverse: bool | MISSING_TYPE = DEPRECATED, + underline: bool | MISSING_TYPE = DEPRECATED, + collapsible: bool | MISSING_TYPE = DEPRECATED, ) -> NavSetBar: """ Render nav items as a navbar. @@ -1223,24 +1412,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 +1480,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 +1504,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..98c5017f0 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, 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: ( + Literal["static-top", "fixed-top", "fixed-bottom"] | MISSING_TYPE + ) = DEPRECATED, + bg: str | None | MISSING_TYPE = DEPRECATED, + inverse: bool | MISSING_TYPE = DEPRECATED, + underline: bool | MISSING_TYPE = DEPRECATED, + collapsible: bool | MISSING_TYPE = 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 f5b3b3430..e1e6c9f4b 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()