Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Sidebar improvements at smaller screen sizes #1436

Merged
merged 15 commits into from
Jul 16, 2024
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Other changes

* The main content area of `ui.page_sidebar()` and `ui.page_navbar()` with a page-level `sidebar` now have a minimum height and width to avoid squashed content in fillable layouts. The minimum height and width are controllable via `--bslib-page-main-min-{width,height}` CSS variables. (#1436)

* Added a new option to place an always-open sidebar *above the main content* on mobile screens by providing `open={"mobile": "always-above"}` to `ui.sidebar()`. (#1436)


## [0.10.2] - 2024-05-28

### Bug fixes
Expand Down
1 change: 1 addition & 0 deletions shiny/experimental/ui/_card.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ def card_image(
container: ImgContainer = card_body,
**kwargs: TagAttrValue,
) -> Tagifiable:
# TODO-future: Must be updated to match rstudio/bslib#1076 before moving from exp.
gadenbuie marked this conversation as resolved.
Show resolved Hide resolved
"""
A card image container

Expand Down
11 changes: 10 additions & 1 deletion shiny/ui/_navs.py
Original file line number Diff line number Diff line change
Expand Up @@ -989,6 +989,7 @@ class NavSetBar(NavSet):
underline: bool
collapsible: bool
fluid: bool
_is_page_level: bool

def __init__(
self,
Expand Down Expand Up @@ -1032,6 +1033,7 @@ def __init__(
self.underline = underline
self.collapsible = collapsible
self.fluid = fluid
self._is_page_level = False

def layout(self, nav: Tag, content: Tag) -> TagList:
nav_container = div(
Expand Down Expand Up @@ -1089,13 +1091,20 @@ def layout(self, nav: Tag, content: Tag) -> TagList:
if self.fillable:
content_div = as_fillable_container(as_fill_item(content_div))
else:
tab_content = contents
if self._is_page_level:
# TODO: This could also be applied to the non-sidebar page layout above
gadenbuie marked this conversation as resolved.
Show resolved Hide resolved
from ._page import page_main_container

tab_content = page_main_container(*contents)

content_div = div(
# 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,
layout_sidebar(
self.sidebar,
contents,
tab_content,
fillable=self.fillable is not False,
border_radius=False,
border=not self.fluid,
Expand Down
53 changes: 33 additions & 20 deletions shiny/ui/_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
from ._tag import consolidate_attrs
from ._utils import get_window_title
from .css import CssUnit, as_css_padding, as_css_unit
from .fill._fill import as_fillable_container
from .fill._fill import add_fill_classes, as_fillable_container

page_sidebar_default: SidebarOpen = SidebarOpen(desktop="open", mobile="always")

Expand Down Expand Up @@ -128,7 +128,7 @@ def page_sidebar(
navbar_title,
layout_sidebar(
sidebar,
*children,
page_main_container(*children),
attrs,
fillable=fillable,
border=False,
Expand All @@ -143,6 +143,14 @@ def page_sidebar(
)


def page_main_container(*args: TagChild) -> Tag:
return add_fill_classes(
tags.main({"class": "bslib-page-main bslib-gap-spacing"}, *args),
fill_item=True,
fillable_container=True,
)


@no_example()
def page_navbar(
*args: NavSetArg | MetadataNode | Sequence[MetadataNode],
Expand Down Expand Up @@ -259,26 +267,31 @@ def page_navbar(

tagAttrs: TagAttrs = {"class": pageClass}

navbar = navset_bar(
*args,
title=title,
id=resolve_id_or_none(id),
selected=selected,
sidebar=sidebar,
fillable=fillable,
gap=gap,
padding=padding,
position=position,
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
# navbar with a global sidebar)
navbar._is_page_level = True

page_args = (
tagAttrs,
navset_bar(
*args,
title=title,
id=resolve_id_or_none(id),
selected=selected,
sidebar=sidebar,
fillable=fillable,
gap=gap,
padding=padding,
position=position,
header=header,
footer=footer,
bg=bg,
inverse=inverse,
underline=underline,
collapsible=collapsible,
fluid=fluid,
),
navbar,
get_window_title(title, window_title=window_title),
)
page_kwargs = {
Expand Down
87 changes: 63 additions & 24 deletions shiny/ui/_sidebar.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import warnings
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Literal, Optional, cast
from typing import TYPE_CHECKING, Literal, Optional, Union, cast

from htmltools import (
HTML,
Expand Down Expand Up @@ -53,10 +53,14 @@
* `"always"`: the sidebar is always open and cannot be closed
"""

SidebarOpenValueMobile = Union[SidebarOpenValue, Literal["always-above"]]

_always_open_mobile = ("always", "always-above")


class SidebarOpenSpec(TypedDict):
desktop: SidebarOpenValue
mobile: SidebarOpenValue
mobile: SidebarOpenValueMobile


@dataclass
Expand All @@ -68,7 +72,7 @@ class SidebarOpen:
desktop: SidebarOpenValue = "open"
"""The initial state of the sidebar on desktop screen sizes."""

mobile: SidebarOpenValue = "closed"
mobile: SidebarOpenValueMobile = "closed"
"""The initial state of the sidebar on mobile screen sizes."""

_VALUES: tuple[SidebarOpenValue, ...] = field(
Expand All @@ -79,14 +83,27 @@ class SidebarOpen:
)

@staticmethod
def _values_str() -> str:
return f"""'{"', '".join(SidebarOpen._VALUES)}'"""
def _values_str(values: tuple[str, ...] | None = None) -> str:
quoted = [f'"{x}"' for x in (values or SidebarOpen._VALUES)]
ret = ""
for i, value in enumerate(quoted):
if i > 0 and len(quoted) > 2:
ret += ","
if i == len(quoted) - 1:
ret += " or"
if i > 0:
ret += " "
ret += value
return ret
gadenbuie marked this conversation as resolved.
Show resolved Hide resolved

def __post_init__(self):
if self.desktop not in self._VALUES:
raise ValueError(f"`desktop` must be one of {self._values_str()}")
if self.mobile not in self._VALUES:
raise ValueError(f"`mobile` must be one of {self._values_str()}")
mobileValues = tuple(str(x) for x in set((*self._VALUES, *_always_open_mobile)))
if self.mobile not in mobileValues:
raise ValueError(
f"`mobile` must be one of {self._values_str(mobileValues)}"
)

@classmethod
def _from_string(cls, open: str) -> SidebarOpen:
Expand Down Expand Up @@ -220,7 +237,9 @@ class directly. Instead, supply the :func:`~shiny.ui.sidebar` object to
Alternatively, you can provide a dictionary with keys `"desktop"` and `"mobile"`
to set different initial states for desktop and mobile. For example, when
`{"desktop": "open", "mobile": "closed"}` the sidebar is initialized in the
open state on desktop screens or in the closed state on mobile screens.
open state on desktop screens or in the closed state on mobile screens. You can
also choose to place an always-open sidebar above the main content on mobile
devices by setting `open={"mobile": "always-above"}`.
id
A character string. Required if wanting to reactively read (or update) the
`collapsible` state in a Shiny app.
Expand Down Expand Up @@ -319,10 +338,10 @@ def open(
def max_height_mobile(self) -> Optional[str]:
max_height_mobile = self._max_height_mobile

if max_height_mobile is not None and self.open().mobile != "always":
if max_height_mobile is not None and self.open().mobile in _always_open_mobile:
gadenbuie marked this conversation as resolved.
Show resolved Hide resolved
warnings.warn(
"The `shiny.ui.sidebar(max_height_mobile=)` argument only applies to "
+ "the sidebar when `open` is `'always'` on mobile, but "
+ f"the sidebar when `open` is {SidebarOpen._values_str(_always_open_mobile)} on mobile, but "
+ f"`open` is `'{self.open().mobile}'`. "
+ "The `max_height_mobile` argument will be ignored.",
# `stacklevel=2`: Refers to the caller of `.max_height_mobile` property method
Expand Down Expand Up @@ -350,6 +369,12 @@ def _as_open(

return SidebarOpen._as_open(open)

def _is_always_open(self) -> bool:
return (
self.open().desktop == "always"
and self.open().mobile in _always_open_mobile
)

def _get_sidebar_id(self) -> Optional[str]:
"""
Returns the resolved ID of the sidebar, or `None` if the sidebar is always open.
Expand All @@ -359,25 +384,26 @@ def _get_sidebar_id(self) -> Optional[str]:
if isinstance(self.id, ResolvedId):
return self.id

if self.open().desktop == "always" and self.open().mobile == "always":
if self._is_always_open():
return None

return private_random_id("bslib_sidebar")

def _collapse_tag(self, id: str | None) -> Tag:
"""Create the <button> tag for the collapse button."""
is_expanded = self.open().desktop == "open" or self.open().mobile == "open"
is_always = self.open() == SidebarOpen(desktop="always", mobile="always")

return tags.button(
_collapse_icon(),
class_="collapse-toggle",
type="button",
title="Toggle sidebar",
aria_expanded=(
("true" if is_expanded else "false") if not is_always else None
("true" if is_expanded else "false")
if not self._is_always_open()
else None
),
aria_controls=id if not is_always else None,
aria_controls=id if not self._is_always_open() else None,
)

def _sidebar_tag(self, id: str | None) -> Tag:
Expand Down Expand Up @@ -647,22 +673,26 @@ def layout_sidebar(
if fillable:
main = as_fillable_container(main)

if sidebar.open().mobile == "always-above":
contents = (sidebar, main)
else:
contents = (main, sidebar)

res = div(
{"class": "bslib-sidebar-layout bslib-mb-spacing"},
{"class": "sidebar-right"} if sidebar.position == "right" else None,
{"class": "sidebar-collapsed"} if sidebar.open().desktop == "closed" else None,
main,
sidebar,
*contents,
components_dependencies(),
_sidebar_init_js(),
data_bslib_sidebar_init="true",
data_open_desktop=sidebar.open().desktop,
data_open_mobile=sidebar.open().mobile,
data_collapsible_mobile=(
"true" if sidebar.open().mobile != "always" else "false"
"false" if sidebar.open().mobile in _always_open_mobile else "true"
),
data_collapsible_desktop=(
"true" if sidebar.open().desktop != "always" else "false"
"false" if sidebar.open().desktop == "always" else "true"
),
data_bslib_sidebar_border=trinary(border),
data_bslib_sidebar_border_radius=trinary(border_radius),
Expand Down Expand Up @@ -700,33 +730,42 @@ def _get_layout_sidebar_sidebar(

if not isinstance(sidebar, Sidebar):
raise ValueError(
"`layout_sidebar()` is not being supplied with a `sidebar()` object. Please supply a `sidebar()` object to `layout_sidebar(sidebar)`."
"`layout_sidebar()` is not being supplied with a `sidebar()` object. "
"Please supply a `sidebar()` object to `layout_sidebar(sidebar)`."
)

# Use `original_args` here so `updated_args` can be safely altered in place
for i, arg in zip(range(len(original_args)), original_args):
if isinstance(arg, DeprecatedPanelSidebar):
raise ValueError(
"`panel_sidebar()` is not being used as the first argument to `layout_sidebar(sidebar,)`. `panel_sidebar()` has been deprecated and will go away in a future version of Shiny. Please supply `panel_sidebar()` arguments directly to `args` in `layout_sidebar(sidebar)` and use `sidebar()` instead of `panel_sidebar()`."
"`panel_sidebar()` is not being used as the first argument to `layout_sidebar(sidebar,)`. "
"`panel_sidebar()` has been deprecated and will go away in a future version of Shiny. "
"Please supply `panel_sidebar()` arguments directly to `args` in `layout_sidebar(sidebar)` and use `sidebar()` instead of `panel_sidebar()`."
)
elif isinstance(arg, Sidebar):
raise ValueError(
"`layout_sidebar()` is being supplied with multiple `sidebar()` objects. Please supply only one `sidebar()` object to `layout_sidebar()`."
"`layout_sidebar()` is being supplied with multiple `sidebar()` objects. "
"Please supply only one `sidebar()` object to `layout_sidebar()`."
)

elif isinstance(arg, DeprecatedPanelMain):
if i != 0:
raise ValueError(
"`panel_main()` is not being supplied as the second argument to `layout_sidebar()`. `panel_main()`/`panel_sidebar()` have been deprecated and will go away in a future version of Shiny. Please supply `panel_main()` arguments directly to `args` in `layout_sidebar(sidebar, *args)` and use `sidebar()` instead of `panel_sidebar()`."
"`panel_main()` is not being supplied as the second argument to `layout_sidebar()`. "
"`panel_main()`/`panel_sidebar()` have been deprecated and will go away in a future version of Shiny. "
"Please supply `panel_main()` arguments directly to `args` in `layout_sidebar(sidebar, *args)` and use `sidebar()` instead of `panel_sidebar()`."
)
if not isinstance(sidebar_orig_arg, DeprecatedPanelSidebar):
raise ValueError(
"`panel_main()` is not being used with `panel_sidebar()`. `panel_main()`/`panel_sidebar()` have been deprecated and will go away in a future version of Shiny. Please supply `panel_main()` arguments directly to `args` in `layout_sidebar(sidebar, *args)` and use `sidebar()` instead of `panel_sidebar()`."
"`panel_main()` is not being used with `panel_sidebar()`. "
"`panel_main()`/`panel_sidebar()` have been deprecated and will go away in a future version of Shiny. "
"Please supply `panel_main()` arguments directly to `args` in `layout_sidebar(sidebar, *args)` and use `sidebar()` instead of `panel_sidebar()`."
)

if len(args) > 2:
raise ValueError(
"Unexpected extra legacy `*args` have been supplied to `layout_sidebar()` in addition to `panel_main()` or `panel_sidebar()`. `panel_main()` has been deprecated and will go away in a future version of Shiny. Please supply `panel_main()` arguments directly to `args` in `layout_sidebar(sidebar, *args)` and use `sidebar()` instead of `panel_sidebar()`."
"Unexpected extra legacy `*args` have been supplied to `layout_sidebar()` in addition to `panel_main()` or `panel_sidebar()`. `panel_main()` has been deprecated and will go away in a future version of Shiny. "
"Please supply `panel_main()` arguments directly to `args` in `layout_sidebar(sidebar, *args)` and use `sidebar()` instead of `panel_sidebar()`."
)
# Notes for this point in the code:
# * We are working with args[0], a `DeprecatedPanelMain`; sidebar was originally a `DeprecatedPanelSidebar`
Expand Down
12 changes: 12 additions & 0 deletions shiny/ui/fill/_fill.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,3 +215,15 @@ def is_fill_item(tag: object) -> bool:
# renders_to_tag_class(x, FILL_ITEM_CLASS, ".html-widget")

return isinstance(tag, Tag) and tag.has_class(FILL_ITEM_CLASS)


def add_fill_classes(
gadenbuie marked this conversation as resolved.
Show resolved Hide resolved
tag: Tag, fill_item: bool = True, fillable_container: bool = True
) -> Tag:
if fill_item:
tag.add_class(FILL_ITEM_CLASS)
if fillable_container:
tag.add_class(FILL_CONTAINER_CLASS)
if fill_item or fillable_container:
tag.append(fill_dependency())
return tag
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With this being so close to the release, I'd rather use the existing as_fill_item()/as_fillable_container().

There is also the subtle copy() issue here, which I know isn't a problem in the way you're currently using it, but it'd be very easy for someone use this in future and unknowingly mutate user input

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean, the point is to avoid the unnecessary copy for elements we're creating internally. But OTOH I can see that it's worth avoiding the potential for mutating user input or having to document two separate approaches, plus it's only a shallow copy of the outer tag anyway. I'll go back to the existing functions.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FTR, we actually make two copies because we need to call as_fill_item(as_fill_carrier(tag)) and both make a copy.

Personally, I'd definitely prefer what @schloerke recommended above: having add_fill_classes() for internal usage and also calling it directly in as_fill_item() and as_fill_carrier(). I think a reasonable docstring on add_fill_classes() would help with the mutating inputs issue.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Anyway, 5bbe19e reverts back to using as_fill_item(as_fill_carrier(main))

2 changes: 1 addition & 1 deletion shiny/www/shared/bootstrap/_version.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"note!": "Generated by scripts/htmlDependencies.R: do not edit by hand",
"shiny_version": "1.8.1.9001 (rstudio/shiny@0b7fda707e4846d2de06f63db312ac35dacf503a)",
"bslib_version": "0.7.0.9000 (rstudio/bslib@c5244cfae75c434e5fae6091820c13b62de53fd7)",
"bslib_version": "0.7.0.9000 (rstudio/bslib@2b4bfc5a4d2e40b48338a38361ae8d453001edf8)",
"htmltools_version": "0.5.8.9000 (rstudio/htmltools@487aa0bed7313d7597b6edd5810e53cab0061198)",
"bootstrap_version": "5.3.1"
}
2 changes: 1 addition & 1 deletion shiny/www/shared/bootstrap/bootstrap.min.css

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion shiny/www/shared/bslib/_version.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"note!": "Generated by scripts/htmlDependencies.R: do not edit by hand",
"package": "bslib",
"version": "0.7.0.9000 (rstudio/bslib@c5244cfae75c434e5fae6091820c13b62de53fd7)"
"version": "0.7.0.9000 (rstudio/bslib@2b4bfc5a4d2e40b48338a38361ae8d453001edf8)"
gadenbuie marked this conversation as resolved.
Show resolved Hide resolved
}
2 changes: 1 addition & 1 deletion shiny/www/shared/bslib/components/components.css

Large diffs are not rendered by default.

Loading
Loading