agent smith: “why, mr. anderson? why, why? why do you do it? why, why get up? why keep fighting? do you believe you’re fighting… for something? for more than your survival? can you tell me what it is? do you even know? is it freedom? or truth? perhaps peace? could it be for love? illusions, mr. anderson. vagaries of perception. temporary constructs of a feeble human intellect trying desperately to justify an existence that is without meaning or purpose. and all of them as artificial as the matrix itself, although… only a human mind could invent something as insipid as love. you must be able to see it, mr. anderson. you must know it by now. you can’t win. it’s pointless to keep fighting. why, mr. anderson? why? why do you persist?”
neo: “because i choose to.”
— matrix revolutions, 2003
if interested to see how my thought process has changed over time, here are the previous articles:
let’s create a barebones shiny app with a download button in the ui and the corresponding download handler in the server portion:
app.R
library(shiny)library(bslib)ui <-page(theme =bs_theme(version =5L), tags$div(class ="container", tags$h3("downloading spinner"), tags$p("works for all download btns in your app & modules..."),downloadButton(outputId ="dnld",class ="btn-sm rounded-3" ) ))server <- \(input, output, session) { output$dnld <-downloadHandler(filename = \() {"iris.csv" },content = \(file) {Sys.sleep(3) # <-- simulate large filewrite.csv(iris, file) } )}shinyApp(ui, server)
great! now when you run that and click the download button, you will have a bad user experience. to the user, the app is seemingly silent: no indication of a download in progress.
add onclick events to download btns
we’re going to write some js in ./public/main.js.
first, let’s ensure:
static resources are accessible in our shiny app via the /static/ prefix. we’ll use shiny::addResourcePath()
the js file is loaded in our app using tags$script().
Code
app.R
library(shiny)library(bslib)# make static resources accessible via "/static":addResourcePath(prefix ="static", directoryPath ="./public")ui <-page(theme =bs_theme(version =5L), tags$div(class ="container", tags$h3("downloading spinner"), tags$p("works for all download btns in your app & modules..."),downloadButton(outputId ="dnld",class ="btn-sm rounded-3" ) ), tags$script(src ="/static/main.js") # <-- load js file)server <- \(input, output, session) { output$dnld <-downloadHandler(filename = \() {"iris.csv" },content = \(file) {Sys.sleep(3) # <-- simulate large filewrite.csv(iris, file) } )}shinyApp(ui, server)
all shiny download buttons have the css class “.shiny-download-link”. we attach a click event to all btns in our app with that class.
2
selects the btn itself, as a jquery object. jquery objects have lots of useful methods.
3
create a bootstrap 5 spinner + label. see reference. this is what will be shown when a dnld btn is clicked.
4
change the inner html of the dnld btn. see reference.
5
set the aria-disabled attribute of the dnld btn to true. this indicates to assistive technologies like screen readers that an element is disabled but still focusable. read more about aria-disabled.
now when you run the app and click the download button, it should show the spinner and the new label.
but wait… it doesn’t revert back to the initial label once the download is complete.
what we need to do is tell the browser when the download is complete, and change the inner html to what it was before.
to inform the frontend that the download is complete, we’ll send a custom message from the server once downloadHandler() is done.
in app.R, let’s create reset_download_button():
app.R
#' Reset Download Button#'#' @param id String. Output ID of the download button to reset.#' @param session Shiny session object. Defaults to the current reactive domain.#'#' @details#' This function sends a custom message of type "end-download" to the client,#' which should be handled by corresponding JavaScript code. The message#' includes the namespaced button ID and inner HTML content.#'#' @return NULL. Called for side effects.#'#' @examples#' \dontrun{#' reset_download_button(id = "my_download")#' }#'#' @kewords internal#' @noRdreset_download_button <- \( id,session = shiny::getDefaultReactiveDomain()) { session$sendCustomMessage(type ="end-download",list(id = session$ns(id),inner_html ="<i class='fas fa-download' aria-label='download icon' role='presentation'></i> Download" ) )}
once downloadHandler() is done, we want to send a custom message of type “end-download” with 2 items:
id: the namespace btn id.
inner_html: the new inner html to use for the dnld btn.
let’s now update the server portion of our app to:
we use on.exit() so that the “end-download” message is sent even when downloadHandler() exits with an error.
here’s where app.R is at now:
Code
app.R
library(shiny)library(bslib)addResourcePath(prefix ="/static", directoryPath ="./public")#' Reset Download Button#'#' @param id String. Output ID of the download button to reset.#' @param session Shiny session object. Defaults to the current reactive domain.#'#' @details#' This function sends a custom message of type "end-download" to the client,#' which should be handled by corresponding JavaScript code. The message#' includes the namespaced button ID and inner HTML content.#'#' @return NULL. Called for side effects.#'#' @examples#' \dontrun{#' reset_download_button(id = "my_download")#' }#'#' @kewords internal#' @noRdreset_download_button <- \( id,session = shiny::getDefaultReactiveDomain()) { session$sendCustomMessage(type ="end-download",list(id = session$ns(id),inner_html ="<i class='fas fa-download' aria-label='download icon' role='presentation'></i> Download" ) )}ui <-page(theme =bs_theme(version =5L), tags$div(class ="container", tags$h3("downloading spinner"), tags$p("works for all download btns in your app & modules..."),downloadButton(outputId ="dnld",class ="btn-sm rounded-3" ) ), tags$script(src ="/static/main.js"))server <- \(input, output, session) { output$dnld <-downloadHandler(filename = \() {"iris.csv" },content = \(file) {on.exit({reset_download_button(id ="dnld", session = session) })Sys.sleep(3) # <-- simulate large filewrite.csv(iris, file) } )}shinyApp(ui, server)
after sending the “end-download” custom message from the server, it must be handled on the client-side. let’s add this to ./public/main.js:
add a custom message handler for messages send from the server of type “end-download”. the custom handler is a callback which takes the message as a parameter.
2
grab the dnld btn id from the message and select the element with that id.
3
set inner html of the btn to the value of inner_html from the message.