library(plotly)
source(
  rprojroot::find_package_root_file("vignettes/_common.R")
)
knitr::opts_chunk$set(echo = FALSE)

```{scss, echo = FALSE} .section.level2 { margin-top: 2rem; } .section.level3 { margin-top: 1rem; }

This article covers the building blocks of filling layouts in `bslib`: **fillable containers** and **fill items**. Filling layout is an inherently nuanced topic, and fully groking takes some effort, but you'll gain a powerful way to get UI elements to fill the window, fill inside [cards](../cards), fill inside [sidebar layouts](../sidebars), or generally fill anywhere you want.

Since, in theory, essentially any UI element can be coerced into a **fillable container** and/or **fill item**[^flexbox], it's useful to first study their behavior in the abstract, which we do next in the [In theory](#in-theory) section. After, the [In practice](#in-practice) section reinforces those concepts and demonstrates their power with practical examples.

[^flexbox]: Technically speaking, a **fillable container** is just a [CSS flexbox](https://css-tricks.com/snippets/css/a-guide-to-flexbox/) container with `flex-direction: column` (which makes it's children flex items). And, when a **fill item** appears as a direct child of a **fillable container**, it is given a `flex` property of `1` (i.e., it's allowed to grow/shrink).

Throughout these sections, be aware that most `bslib` components, as well as many Shiny outputs (e.g., `plotOutput()`, `plotlyOutput()`, etc) classify as [fill items]{.b-blue} by default. This means they possess the potential to grow/shrink to fit their container, but that potential is only activated when their _immediate parent_ is a [fillable container]{.b-pink} _with a defined height_. Also be aware that `bslib` components and many Shiny outputs have `fill` and/or `fillable` arguments to opt out/in of this behavior (and `bslib` also provides an API for testing/coercing these properties on _any_ UI element -- see `is_fill()` for more).

## In theory

```r
fillable_container <- function(..., tooltip = NULL, resizable = FALSE, padding = TRUE) {
  gap <- "0.25rem"
  card(
    "data-bs-toggle" = if (!is.null(tooltip)) "tooltip",
    "data-bs-title" = paste(tooltip, collapse = "<br>"),
    "data-bs-html" = "true",
    "data-bs-placement" = "top",
    "data-bs-custom-class" = "tip-pink",
    style = css(
      "--bs-card-border-color" = "var(--bs-pink)",
      "--bs-card-border-width" = "0.12rem",
      gap = if (padding) gap,
      resize = if (resizable) "vertical"
    ),
    ...,
    wrapper = function(...) {
      card_body(
        class = if (padding) "p-1" else "p-0",
        gap = if (padding) gap,
        ...
      )
    }
  )
}


child <- function(..., fill = TRUE, fillable = FALSE, tooltip = NULL, placement = "right", height = 400) {
  both <- fill && fillable
  color <- if (both) "purple" else if (fill) "blue" else "orange"
  div(
    class = "border border-1 border-dark rounded text-center",
    class = paste0("bg-fill-", color),
    "data-bs-toggle" =  if (!is.null(tooltip)) "tooltip",
    "data-bs-title" = paste(tooltip, collapse = "<br>"),
    "data-bs-html" = "true",
    "data-bs-placement" = placement,
    "data-bs-custom-class" = paste0("tip-", color),
    style = css(
      color = "white",
      "--bs-tooltip-bg" = sprintf("var(--bs-%s)", color),
      height = validateCssUnit(height),
      flex_shrink = if (!fill) 0,
      padding = "0.2rem"
    ),
    ...
  ) |>
    bindFillRole(item = fill, container = fillable)
}

```{scss, echo=FALSE} .tip-pink { --bs-tooltip-bg: var(--bs-pink); } .tip-blue { --bs-tooltip-bg: var(--bs-blue); } .tip-orange { --bs-tooltip-bg: var(--bs-orange); } .tip-purple { --bs-tooltip-bg: var(--bs-purple); }

.b-pink { font-weight: bold; color: var(--bs-pink); } .b-orange { font-weight: bold; color: var(--bs-orange); } .b-blue { font-weight: bold; color: hsl(202deg, 100%, 38%); } .b-purple { font-weight: bold; color: var(--bs-purple); }

.bg-fill-blue { background-image: linear-gradient( 180deg, hsl(202deg, 100%, 38%) 0%, hsl(206deg, 58%, 55%) 25%, hsl(206deg, 58%, 66%) 50%, hsl(205deg, 61%, 77%) 75%, hsl(203deg, 69%, 87%) 100% ); }

.bg-fill-orange { background-image: linear-gradient( 180deg, hsl(27deg, 98%, 54%) 0%, hsl(27deg, 97%, 64%) 25%, hsl(27deg, 93%, 73%) 50%, hsl(28deg, 79%, 82%) 75%, hsl(28deg, 27%, 90%) 100% ); }

.bg-fill-purple { background: var(--bs-purple); }

### Activating fill

::: row
::: col-md-6
Just like any other HTML **container**, a [fillable container]{.b-pink}'s default height depends on the height of it's children. So, for example, if there's a single [fill item]{.b-blue} with a defined height of `400px` (the default for most Shiny outputs), the [fillable container]{.b-pink}'s height is also `400px` (plus any padding, border, etc).
:::

::: col-md-6
```r
fillable_container(
  tooltip = c("Fillable container", "Computed height: 400px"),
  child(
    tooltip = c("Fill child", "Defined height: 400px")
  )
)

::: :::

::: {.row .mt-3} ::: col-md-6 Defining the height of a [fillable container]{.b-pink} activates its immediate children's potential to [fill]{.b-blue}. So, for example, if [fillable container]{.b-pink}'s height is set to 200px, the [fill child]{.b-blue} would shrink to about 200px: :::

::: col-md-6

fillable_container(
  tooltip = c("Fillable", "Defined height: 200px"),
  child(
    tooltip = c("Fill", "Defined height: 400px", "Computed height: 200px")
  ),
  height = 200
)

::: :::

::: {.row .mt-3} ::: col-md-6 If multiple [fill items]{.b-blue} were immediate children of this [fillable container]{.b-pink}, they'd keep shrinking (in this case, to about 100px each): :::

::: col-md-6

fillable_container(
  tooltip = c("Fillable", "Defined height: 200px"),
  child(
    tooltip = c("Fill", "Defined height: 400px", "Computed height: 100px")
  ),
  child(
    tooltip = c("Fill", "Defined height: 400px", "Computed height: 100px")
  ),
  height = 200
)

::: :::

::: {.row .mt-3} ::: col-md-6 Adding a [non-fill]{.b-orange} item (e.g., htmltools::p()-aragraph of text) won't cause that particular item to grow/shrink, but the [fill]{.b-blue} items divvy up any remaining space (careful: if [non-fill]{.b-orange} item(s) are larger than the [fillable container]{.b-pink}, the [fill]{.b-blue} items won't be visible!). This is big reason why card()s have a min_height argument (to prevent [fill]{.b-blue} items from shrinking too much). :::

::: col-md-6

fillable_container(
  tooltip = c("Fillable container"),
  child(tooltip = "Fill"),
  child(tooltip = "Fill"),
  child(
    fill = FALSE, height = 100,
    tooltip = "Non-fill"
  ),
  height = 200,
  resizable = TRUE
)

::: :::

::: {.callout .callout-warning}

Resizable example

Notice the resizing handle on the lower-right hand corner of the [fillable container]{.b-pink} above. Use it to change the size of the [fillable container]{.b-pink} and compare the behavior between [fill]{.b-blue} and [non-fill]{.b-orange} items. :::

Carrying fill

The previous section focuses on the fairly simple case of one parent container. However, in practice, you'll likely be working with multiple levels of parents, which quickly complicates things, especially because:

  1. [Fill items]{.b-blue} require their immediate parent to be a [fillable container]{.b-pink} in order to fill.
  2. All "raw" HTML tags (e.g., div(), p(), etc.) as well as many Shiny UI elements (e.g., wellPanel(), etc.) are neither [fillable]{.b-pink} nor [fill]{.b-blue} (i.e., we'll call these [non-fill]{.b-orange} elements).

::: {.row .mt-3} ::: col-md-6 As a result, a common way in which (1) breaks down is that a [non-fill]{.b-orange} element, like a div(), comes between [fillable]{.b-pink} and [fill]{.b-blue}. In fact, you'll run into this exact behavior when using uiOutput() to insert a dynamically rendered [fill]{.b-blue} item into a [fillable container]{.b-pink} (see this section for a concrete example). :::

::: col-md-6

fillable_container(
  tooltip = "Fillable container",
  child(
    fill = FALSE,
    tooltip = "Non-fill",
    "data-bs-offset" = "-140,0",
    child(
      tooltip = "Fill",
      "data-bs-offset" = "-100,0"
    )
  ),
  height = 200,
  resizable = TRUE
)

::: :::

::: {.row .mt-3} ::: col-md-6 Assuming the goal is for the [fill item]{.b-blue} to fit the [fillable container]{.b-pink}, it's useful to coerce the [non-fill]{.b-orange} element into both [fill item]{.b-blue} and a [fillable container]{.b-pink}, which we call a [fill carriers]{.b-purple}. Any UI element can be coerced into a [fill carrier]{.b-purple} with as_fill_carrier(). :::

::: col-md-6

fillable_container(
  tooltip = "Fillable container",
  child(
    fillable = TRUE,
    tooltip = "Fill carrier",
    "data-bs-offset" = "-40,0",
    child(tooltip = "Fill")
  ),
  height = 200,
  resizable = TRUE
)

::: :::

::: {.row .mt-3} ::: col-md-6 This concept of a [fill carrier]{.b-purple} is especially useful and relevant for cards. In most cases, a [card]{.b-pink} has numerous children like a [header]{.b-orange} and a [body]{.b-purple}, and the body commonly contains [fill item]{.b-blue}(s) (to ensure [fill item]{.b-blue}s). This is why card_body(){.b-purple} defaults to fillable = TRUE (and fill = TRUE). :::

::: col-md-6

fillable_container(
  tooltip = c("<code>card()</code> (fillable container)"),
  child(
    fill = FALSE, height = 50,
    tooltip = "<code>card_header()</code>"
  ),
  child(
    fillable = TRUE,
    tooltip = "<code>card_body()</code>",
    child(
      tooltip = "Fill",
      "data-bs-offset" = "-30,0"
    )
  ),
  height = 200,
  resizable = TRUE
)

::: :::

::: {.row .mt-3} ::: col-md-6 You might wonder, why then would we want or need fillable = FALSE or fill = FALSE on a card_body()? One big reason is that [fillable containers]{.b-pink} are powered by CSS flexbox, which changes the way it's children are rendered. And, although those changes are nice for "stretchy" children, there are downsides for rendering inline elements. So, that's why, it's recommended that you use multiple card bodies when combining [fill]{.b-blue} with [non-fill]{.b-orange} :::

::: col-md-6

fillable_container(
  tooltip = c("<code>card()</code> (fillable container)"),
  child(
    fill = FALSE, height = 50,
    tooltip = "<code>card_header()</code> (not fill)"
  ),
  child(
    fillable = TRUE,
    tooltip = "<code>card_body()</code>",
    child(
      tooltip = "Fill",
      "data-bs-offset" = "-30,0"
    )
  ),
  child(
    fill = FALSE, height = 40,
    tooltip = "<code>card_body(fillable = F, fill = F)</code>",
    placement = "top",
    child(
      fill = FALSE, height = 30,
      tooltip = "Not fill",
    )
  ),
  height = 300,
  resizable = TRUE
)

::: :::

In practice

This section puts into practice what we learned in the theory of [fillable containers]{.b-pink} and [fill items]{.b-blue}.

opts_chunk$set(echo = TRUE)

Setup code

The example in the sub-sections that follow assume you've ran the following code. Here we're using {plotly} to create a list of [fill items]{.b-blue}, but the same concepts extend to other htmlwidgets (e.g., {leaflet}) and Shiny outputs like plotOutput().[^troubleshoot]

[^troubleshoot]: If these techniques don't extend as advertised to your output(s) of choice, see the other advice section to help troubleshoot. If that doesn't help, please let us know about it!

library(plotly)
plots <- list(
  plot_ly(diamonds) |> add_histogram(x = ~price),
  plot_ly(diamonds) |> add_histogram(x = ~carat),
  plot_ly(diamonds) |> add_histogram(x = ~cut, color = ~clarity)
)
plots <- lapply(plots, function(x) {
  config(x, displayModeBar = FALSE) |>
    layout(margin = list(t = 0, b = 0, l = 0, r = 0))
})

Filling the window

Perhaps the most important [fillable container]{.b-pink} is page_fillable(), which sets it's height equal to the browser window. Thus, if [fill items]{.b-blue} appear as direct children, they'll fill the window. page_fillable() also defaults to fillable_mobile = FALSE, which means the height isn't set equal to the viewport on mobile. As a result, fill items use their own defined height (instead of the viewport size) on mobile, which is often better behavior when showing multiple outputs.

page_fillable(
  h2("Diamond plots"),
  plots[[1]], plots[[2]], plots[[3]]
)

:::: {.row .mt-3}

::: {.col-md-6 .mt-auto .mb-auto} ::: {.callout .callout-note}

Resizable example

Notice the resizing handle on the lower-right hand corner of the example above. Use it to change the size of the "window" and see the behavior of the filling plots ::: :::

::: col-md-6 ::: {.callout .callout-note}

Limiting shrinkage

If you're worried about plots becoming too small, consider putting them in a card_body() with a min_height (like we do later on). Also, if you don't want the card border, you can do card(class = "border-0", ...) ::: ::: ::::

Multiple columns

Since layout_columns() is a [fill item]{.b-blue} (by default), it grows/shrinks just like any other [fill item]{.b-blue}. It also defaults to fillable = TRUE, which in this case, means each column gets wrapped in a [fillable container]{.b-pink}. That's why, in this example, plots[[1]] and plots[[1]] also grow/shrink to match the size of the layout_columns() container.

page_fillable(
  h2("Diamond plots"),
  layout_columns(plots[[1]], plots[[2]]),
  plots[[3]]
)

Value boxes

Since value_box() is a [fill item]{.b-blue} (by default), it grows/shrinks just like any other [fill item]{.b-blue}. This is especially useful for keeping a common baseline in a multi-column layout. That said, the multi-layout column that holds value boxes probably doesn't want it default fill = TRUE behavior, since the value boxes should be given more/less space and the window becomes larger/smaller:

boxes <- layout_columns(
  fill = FALSE,
  value_box(
    "Total diamonds",
    scales::comma(nrow(diamonds)),
    showcase = bsicons::bs_icon("gem", size = NULL)
  ),
  value_box(
    "Average price",
    scales::dollar(mean(diamonds$price), accuracy = 1),
    showcase = bsicons::bs_icon("coin", size = NULL),
    theme_color = "success"
  ),
  value_box(
    "Average carat",
    scales::number(mean(diamonds$carat), accuracy = .1),
    showcase = bsicons::bs_icon("search", size = NULL),
    theme_color = "dark"
  )
)

page_fillable(
  boxes,
  layout_columns(plots[[1]], plots[[2]]),
  plots[[3]]
)

::: {.callout .callout-note}

Column wrapping layouts

To learn more about layout_columns(), see this article. :::

Full-screen cards

As alluded to in the Carrying fill section, card() and card_body() are [fill carriers]{.b-purple} (that is, they are both [fillable]{.b-pink} and [fill]{.b-blue}, by default). Therefore, by wrapping each plot in a card, the card not only grows/shrinks (since they are [fill]{.b-blue}), but also retain the plot's ability to grow/shrink (since they are [fillable]{.b-pink}).

plot_card <- function(header, ...) {
  card(
    full_screen = TRUE,
    card_header(header, class = "bg-dark"),
    card_body(..., min_height = 150)
  )
}

page_fillable(
  layout_columns(
    plot_card("Diamond price", plots[[1]]),
    plot_card("Diamond carat", plots[[2]])
  ),
  plot_card("Diamond cut by clarity", plots[[3]])
)

Note that, if we changed page_fillable() to page_fluid() (or page_fixed()), each plot would render to it's default height (400px) since we no longer have a [fillable]{.b-pink} with a specified height. That said, even in that case, if we expand the card to full-screen, the plot still grows to fit the full screen card (since the card() is then a [fillable]{.b-pink} container with a specified height, the card_body() is a [fill carrier]{.b-purple}, and the plot is a [fill item]{.b-blue}).

Sidebar layouts

Similar to what we've seen with outputs and card()s, layout_sidebar() is also a fill item (by default), so placing it as a direct child of page_fillable() makes it fit the window. Also, the main content's container defaults to a fillable container, so if that behavior is undesirable, set fillable = FALSE in layout_sidebar().

page_fillable(
  padding = 0,
  layout_sidebar(
    border = FALSE,
    fillable = FALSE,
    sidebar = sidebar(
      title = "Diamond plots",
      "Input controls here..."
    ),
    layout_columns(
      plot_card("Diamond price", plots[[1]]),
      plot_card("Diamond carat", plots[[2]])
    ),
    plot_card("Diamond cut by clarity", plots[[3]])
  )
)

::: {.callout .callout-note}

Sidebar layouts

To learn more about layout_sidebar(), see this article. :::

Other advice

Dynamic UI {#ui-output}

:::: {.row .mt-3}

::: col-md-6 As alluded to in the Carrying fill section, uiOutput() puts an additional UI element around renderUI()'s return value. So, in order to carry the potential to fill down to a fill item (e.g., plot_ly()), mark uiOutput() as a fill carrier. :::

::: col-md-6

library(plotly)

ui <- page_fluid(
  card(
    full_screen = TRUE,
    max_height = 300,
    card_header("My plot"),
    uiOutput("plot", as_fill_carrier())
  )
)

server <- function(input, output) {
  output$plot <- renderUI({
    plot_ly(diamonds, x = ~price)
  })
}

shinyApp(ui, server)

::: ::::

DT tables

:::: {.row .mt-3}

::: col-md-6 {DT}'s datatable() has it's own unique interface for filling a container. Specifically, make sure to set datatable(fillContainer = TRUE) in order for the table to grow/shrink as you'd expect it to. :::

::: col-md-6

library(DT)

ui <- page_fluid(
  card(
    full_screen = TRUE,
    max_height = 350,
    card_header("My table"),
    dataTableOutput("dt")
  )
)

server <- function(input, output) {
  output$dt <- renderDataTable({
    datatable(
      mtcars, fillContainer = TRUE
    )
  })
}

shinyApp(ui, server)

::: ::::

Other htmlwidgets

Broadly speaking, most htmlwidgets like {plotly} and {leaflet} are [fill items]{.b-blue} by default, but that might not always be the case. Also, sometimes, you might not want a particular widget to be treated as a [fill item]{.b-blue}. In the Shiny case, you should be able to control this through a fill argument on the output container (e.g., plotlyOutput("id", fill = FALSE)), but if no fill argument is available you can also use bslib's as_fill() API to opt in/out. In the non-Shiny case, you can control fill through the widget's htmlwidgets::sizingPolicy() (e.g., leaflet()$sizingPolicy$fill).

Avoid fluidRow()/column()

Modern versions of Bootstrap Grid currently use CSS Flexbox in such a way that filling layout is mostly incompatible with fluidRow()/column(). Instead, use layout_columns() to implement multi-column filling layouts

{=html} <script> const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle=\"tooltip\"]') const tooltipList = [...tooltipTriggerList].map(el => { new bootstrap.Tooltip(el, { trigger: 'hover' }) }) </script>



rstudio/bootstraplib documentation built on June 17, 2024, 9:42 a.m.