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

Improve sidebar initialization #555

Merged
merged 40 commits into from
May 4, 2023
Merged

Improve sidebar initialization #555

merged 40 commits into from
May 4, 2023

Conversation

gadenbuie
Copy link
Member

@gadenbuie gadenbuie commented Apr 25, 2023

The core goal of this PR is to ensure that sidebar layouts are correctly initialized regardless of how they are loaded. There are essentially three methods of inclusion to consider:

  1. A sidebar layout is present in the initial page load.
  2. A sidebar layout is added to the page via insertUI() or renderUI().
  3. Both of the above.

In the final version, we use a <script> tag (because it's easy and available everywhere) paired with a static method Sidebar.initCollapsibleAll() that initializes all collapsible sidebars on the page at once.

This PR also moves from using the Sidebar class with only static methods as a way to collect sidebar-related functions to actually creating a Sidebar instance for each initialized sidebar. There are only two public methods on this class, .isClosed (a getter) and .toggle(), which accepts "open", "close", or "toggle"(implied when.toggle()is called without arguments). These are used in the.getValue()and.receiveMessage()methods of the ShinySidebarInputBinding`. Most of the changes in this PR are around distinguishing between static and instance methods.

Another small fix in this PR: we better coordinate the timing of the collapse toggle icon with the sidebar collapse transition itself to ensure that final state event listener fires (on the toggle icon transitionend) at the same time that the sidebar is fully collapsed.


Historical notes:

I tried a few ideas and have kept my exploration in tact in this PR, but the goal is the same and the approach is relatively similar:

  1. If the page isn't loaded, wait for page load and then initialize all sidebar layouts.
  2. Watch the DOM for new sidebar layouts and initialize them when added.

My first attempt used mutation-summary, which is well-designed but ultimately quite heavy as a library. I then tried arrive.js, which is smaller and even easier to use but still a little too heavy to bundle directly into our minimized component JavaScript. The final approach in this PR is a custom class, DocumentObserver, built around MutationObserver, that does exactly what we need while building directly on browser APIs. All three versions achieve the same result, but the hand-crafted approach using DocumentObserver adds only about 1kB to the minified sidebar JavaScript.

While working on the dynamic UI, I reconfigured our event listeners to be better contained within the Sidebar class. I think this improves readability and it makes it allows us to directly attach the event listeners to targeted elements without having to rely on event bubbling. It also lets us remove unused event listeners when sidebar layouts are removed from the DOM.

It's worth pointing out that sidebar.ts is now just a Sidebar class and the SidebarInputBinding; the DocumentObserver design allows us to set up a private static object on the Sidebar class that handles onload and DOM observing callbacks. It also means that we only need to include the sidebar html_dependency, without needing any initialization code in the markup, and we can have a strong guarantee that any existing or new sidebars will be initialized when added to the page.


Test App
# pak::pak("shiny@1.7.0")
# pak::pak("shiny")

library(shiny)
pkgload::load_all()
library(bslib)

INCLUDE_INITIAL_SIDEBAR <- FALSE

color_pairs <- list(
  list(dark = "#1A2A6C", light = "#AED9E0"),
  list(dark = "#800020", light = "#F6DFD7"),
  list(dark = "#4B0082", light = "#E6E6FA"),
  list(dark = "#006D5B", light = "#A2D5C6")
)

adjectives <- c("charming", "cuddly", "elegant", "fierce", "graceful", "majestic", "playful", "quirky", "silly", "witty")
animals <- c("elephant", "giraffe", "jaguar", "koala", "lemur", "otter", "panda", "panther", "penguin", "zebra")

nested_sidebar <- function(idx = 0L) {
  colors <- color_pairs[[idx %% length(color_pairs) + 1]]

  select_adjective <- function() {
    selectInput(
      paste0("adjective_", idx),
      "Adjective",
      choices = adjectives,
      selected = adjectives[idx %% length(adjectives) + 1]
    )
  }

  select_animal <- function() {
    selectInput(
      paste0("animal_", idx),
      "Animal",
      choices = animals,
      selected = animals[idx %% length(animals) + 1]
    )
  }

  layout_sidebar(
    sidebar = sidebar(
      "Outer Sidebar",
      width = 150,
      bg = colors$dark,
      select_adjective()
    ),
    height = 300,
    class = "p-0",
    fillable = TRUE,
    layout_sidebar(
      sidebar = sidebar(
        "Inner Sidebar",
        width = 150,
        bg = colors$light,
        select_animal()
      ),
      border = FALSE,
      border_radius = FALSE,
      h2("Sidebar Layout", idx),
      uiOutput(paste0("ui_content_", idx)),
    )
  ) |>
  tagAppendAttributes(class = "mb-4")
}

ui <- page_fixed(
  h1("Dynamic Sidebars"),
  tags$head(tags$title("bslib | Tests | Dynamic Sidebars")),
  p(
    "Test dynamically added sidebars.",
    "Each new layout is a nested layout with two sidebars.",
    "The sidebar collapse toggles should not overlap when collapsed",
    "and if you add the sidebar while in mobile screen width the sidebars",
    "will be closed when added."
  ),
  layout_column_wrap(
    width = 500,
    id = "sidebar-here",
    if (INCLUDE_INITIAL_SIDEBAR) nested_sidebar()
  ),
  actionButton("add_sidebar", "Add sidebar layout"),
  actionButton("remove_sidebar", "Remove sidebar layout")
)

server <- function(input, output, session) {
  idx <- 0L

  output_nested_sidebar <- function(idx) {
    output_id <- paste0("ui_content_", idx)
    adjective_id <- paste0("adjective_", idx)
    animal_id <- paste0("animal_", idx)

    output[[output_id]] <- renderUI({
      p(sprintf("Hello, %s %s!", input[[adjective_id]], input[[animal_id]]))
    })
  }

  if (INCLUDE_INITIAL_SIDEBAR) {
    observe({
      isolate(output_nested_sidebar(0))
    })
  }

  observeEvent(input$add_sidebar, {
    idx <<- idx + 1L

    insertUI(
      selector = "#sidebar-here",
      where = "beforeEnd",
      ui = nested_sidebar(idx)
    )

    output_nested_sidebar(idx)
  })

  observeEvent(input$remove_sidebar, {
    removeUI(selector = "#sidebar-here > :last-child")
  })
}

shinyApp(ui, server)

The above test app was used as the basis for rstudio/shinycoreci#164

Apply click and transition end event listeners directly to the elements that
need them instead of applying them to the document and relying on event
delegation. We also now use vanilla JS to add and remove event listeners instead
of jQuery, and we remove the event listeners if the layouts are removed the
page.
arrive is a lot lighter and can be included in each component directly
@gadenbuie gadenbuie changed the title Use custom MutationObserver to watch for and initialize sidebars Improve sidebar initialization May 4, 2023
R/sidebar.R Outdated Show resolved Hide resolved
R/sidebar.R Outdated Show resolved Hide resolved
@gadenbuie
Copy link
Member Author

@cpsievert This is ready for final review. Also note that this PR pairs with rstudio/shinycoreci#164, which includes tests around the sidebar behavior covered here.

R/sidebar.R Outdated Show resolved Hide resolved
R/sidebar.R Show resolved Hide resolved
@cpsievert
Copy link
Collaborator

Looking good overall. It's up to you whether you want to investigate how a web component might improve/simplify things.

Please also update your original comment to better reflect what this PR does, thanks!

@cpsievert
Copy link
Collaborator

Was it intentional to remove the data attribute in 0cb6d58?

@gadenbuie
Copy link
Member Author

Was it intentional to remove the data attribute in 0cb6d58?

Nope, thanks for catching that! I tried applying the suggestion via vscode and it bungled it 😬

Copy link
Collaborator

@cpsievert cpsievert left a comment

Choose a reason for hiding this comment

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

Heads up @schloerke, we'll probably want to roll these changes into PyShiny (although, it might make sense to wait until #557 is also done)

@gadenbuie gadenbuie merged commit 2a9a21f into main May 4, 2023
@gadenbuie gadenbuie deleted the sidebar/dynamic-ui branch May 4, 2023 20:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants