Enable busy indicators by default, add ability to disable/customize fade (#4104)

* Follow up to #4040: enable busy indicators by default

* Make our spinner invisible when wrapped inside a shinycssloaders::withSpinner() container

* Add the ability to disable/customize recalculating opacity (i.e., fade)

* Fix bug with fade not being applied correctly when the output container has no children

* `devtools::document()` (GitHub Actions)

* `yarn build` (GitHub Actions)

* Update NEWS.md

* Follow up to b7e7af: need to also rest opacity for :empty case (for initial calculation)

* Rd docs fixes/improvements

---------

Co-authored-by: cpsievert <cpsievert@users.noreply.github.com>
This commit is contained in:
Carson Sievert
2024-07-24 12:57:42 -05:00
committed by GitHub
parent bb89cf9235
commit 3f4676d9a6
9 changed files with 111 additions and 27 deletions

10
NEWS.md
View File

@@ -1,13 +1,19 @@
# shiny (development version) # shiny (development version)
## New busy indication feature
Shiny now includes busy indication by default, which more specifically means:
1. Calculating/recalculating outputs now have a spinner overlay.
2. When no outputs are calculating, but Shiny is busy calculating something (e.g., a download, side-effect, etc), a page-level pulsing banner is shown.
If either 1 or 2 leads to undesirable behavior in your app, you can disable them entirely with `useBusyIndicators(spinners = FALSE, pulse = FALSE)`. In addition, various properties of the spinners and pulse can be customized with `busyIndicatorOptions()`. For more details, see `?busyIndicatorOptions`. (#4040, #4104)
## New features and improvements ## New features and improvements
* The client-side TypeScript code for Shiny has been refactored so that the `Shiny` object is now an instance of class `ShinyClass`. (#4063) * The client-side TypeScript code for Shiny has been refactored so that the `Shiny` object is now an instance of class `ShinyClass`. (#4063)
* In TypeScript, the `Shiny` object has a new property `initializedPromise`, which is a Promise-like object that can be `await`ed or chained with `.then()`. This Promise-like object corresponds to the `shiny:sessioninitialized` JavaScript event, but is easier to use because it can be used both before and after the events have occurred. (#4063) * In TypeScript, the `Shiny` object has a new property `initializedPromise`, which is a Promise-like object that can be `await`ed or chained with `.then()`. This Promise-like object corresponds to the `shiny:sessioninitialized` JavaScript event, but is easier to use because it can be used both before and after the events have occurred. (#4063)
* Added new functions, `useBusyIndicators()` and `busyIndicatorOptions()`, for enabling and customizing busy indication. Busy indicators provide a visual cue to users when the server is busy calculating outputs or otherwise serving requests to the client. When enabled, a spinner is shown on each calculating/recalculating output, and a pulsing banner is shown at the top of the page when the app is otherwise busy. (#4040)
* Output bindings now include the `.recalculating` CSS class when they are first bound, up until the first render. This makes it possible/easier to show progress indication when the output is calculating for the first time. (#4039) * Output bindings now include the `.recalculating` CSS class when they are first bound, up until the first render. This makes it possible/easier to show progress indication when the output is calculating for the first time. (#4039)
* A new `shiny.client_devmode` option controls client-side devmode features, in particular the client-side error console introduced in shiny 1.8.1, independently of the R-side features of `shiny::devmode()`. This usage is primarily intended for automatic use in Shinylive. (#4073) * A new `shiny.client_devmode` option controls client-side devmode features, in particular the client-side error console introduced in shiny 1.8.1, independently of the R-side features of `shiny::devmode()`. This usage is primarily intended for automatic use in Shinylive. (#4073)

View File

@@ -19,6 +19,8 @@
#' output. #' output.
#' @param pulse Whether to show a pulsing banner at the top of the page when the #' @param pulse Whether to show a pulsing banner at the top of the page when the
#' app is busy. #' app is busy.
#' @param fade Whether to fade recalculating outputs. A value of `FALSE` is
#' equivalent to `busyIndicatorOptions(fade_opacity=1)`.
#' #'
#' @export #' @export
#' @seealso [busyIndicatorOptions()] for customizing the appearance of the busy #' @seealso [busyIndicatorOptions()] for customizing the appearance of the busy
@@ -48,7 +50,7 @@
#' } #' }
#' #'
#' shinyApp(ui, server) #' shinyApp(ui, server)
useBusyIndicators <- function(..., spinners = TRUE, pulse = TRUE) { useBusyIndicators <- function(..., spinners = TRUE, pulse = TRUE, fade = TRUE) {
rlang::check_dots_empty() rlang::check_dots_empty()
@@ -62,20 +64,33 @@ useBusyIndicators <- function(..., spinners = TRUE, pulse = TRUE) {
} }
}) })
js <- HTML(paste(js, collapse = "\n"))
# TODO: it'd be nice if htmltools had something like a page_attrs() that allowed us # TODO: it'd be nice if htmltools had something like a page_attrs() that allowed us
# to do this without needing to inject JS into the head. # to do this without needing to inject JS into the head.
tags$script(js) res <- tags$script(HTML(paste(js, collapse = "\n")))
if (!fade) {
res <- tagList(res, fadeOptions(opacity = 1))
}
res
} }
#' Customize busy indicator options #' Customize busy indicator options
#' #'
#' When busy indicators are enabled (see [useBusyIndicators()]), a spinner is #' @description
#' shown on each calculating/recalculating output, and a pulsing banner is shown #' Shiny automatically includes busy indicators, which more specifically means:
#' at the top of the page when the app is otherwise busy. This function allows #' 1. Calculating/recalculating outputs have a spinner overlay.
#' you to customize the appearance of those busy indicators. To apply the #' 2. Outputs fade out/in when recalculating.
#' customization, include the result of this function inside the app's UI. #' 3. When no outputs are calculating/recalculating, but Shiny is busy
#' doing something else (e.g., a download, side-effect, etc), a page-level
#' pulsing banner is shown.
#'
#' This function allows you to customize the appearance of these busy indicators
#' by including the result of this function inside the app's UI. Note that,
#' unless `spinner_selector` (or `fade_selector`) is specified, the spinner/fade
#' customization applies to the parent element. If the customization should
#' instead apply to the entire page, set `spinner_selector = 'html'` and
#' `fade_selector = 'html'`.
#' #'
#' @param ... Currently ignored. #' @param ... Currently ignored.
#' @param spinner_type The type of spinner. Pre-bundled types include: #' @param spinner_type The type of spinner. Pre-bundled types include:
@@ -97,6 +112,11 @@ useBusyIndicators <- function(..., spinners = TRUE, pulse = TRUE) {
#' @param spinner_selector A character string containing a CSS selector for #' @param spinner_selector A character string containing a CSS selector for
#' scoping the spinner customization. The default (`NULL`) will apply the #' scoping the spinner customization. The default (`NULL`) will apply the
#' spinner customization to the parent element of the spinner. #' spinner customization to the parent element of the spinner.
#' @param fade_opacity The opacity (a number between 0 and 1) for recalculating
#' output. Set to 1 to "disable" the fade.
#' @param fade_selector A character string containing a CSS selector for
#' scoping the spinner customization. The default (`NULL`) will apply the
#' spinner customization to the parent element of the spinner.
#' @param pulse_background A CSS background definition for the pulse. The #' @param pulse_background A CSS background definition for the pulse. The
#' default uses a #' default uses a
#' [linear-gradient](https://developer.mozilla.org/en-US/docs/Web/CSS/gradient/linear-gradient) #' [linear-gradient](https://developer.mozilla.org/en-US/docs/Web/CSS/gradient/linear-gradient)
@@ -107,7 +127,7 @@ useBusyIndicators <- function(..., spinners = TRUE, pulse = TRUE) {
#' time. #' time.
#' #'
#' @export #' @export
#' @seealso [useBusyIndicators()] for enabling/disabling busy indicators. #' @seealso [useBusyIndicators()] to disable/enable busy indicators.
#' @examplesIf rlang::is_interactive() #' @examplesIf rlang::is_interactive()
#' #'
#' library(bslib) #' library(bslib)
@@ -162,6 +182,8 @@ busyIndicatorOptions <- function(
spinner_size = NULL, spinner_size = NULL,
spinner_delay = NULL, spinner_delay = NULL,
spinner_selector = NULL, spinner_selector = NULL,
fade_opacity = NULL,
fade_selector = NULL,
pulse_background = NULL, pulse_background = NULL,
pulse_height = NULL, pulse_height = NULL,
pulse_speed = NULL pulse_speed = NULL
@@ -177,6 +199,7 @@ busyIndicatorOptions <- function(
delay = spinner_delay, delay = spinner_delay,
selector = spinner_selector selector = spinner_selector
), ),
fadeOptions(opacity = fade_opacity, selector = fade_selector),
pulseOptions( pulseOptions(
background = pulse_background, background = pulse_background,
height = pulse_height, height = pulse_height,
@@ -224,6 +247,26 @@ spinnerOptions <- function(type = NULL, color = NULL, size = NULL, delay = NULL,
tags$style(css, id = id) tags$style(css, id = id)
} }
fadeOptions <- function(opacity = NULL, selector = NULL) {
if (is.null(opacity) && is.null(selector)) {
return(NULL)
}
css_vars <- htmltools::css(
"--shiny-fade-opacity" = opacity
)
id <- NULL
if (is.null(selector)) {
id <- paste0("fade-options-", p_randomInt(100, 1000000))
selector <- sprintf(":has(> #%s)", id)
}
css <- HTML(paste0(selector, " {", css_vars, "}"))
tags$style(css, id = id)
}
pulseOptions <- function(background = NULL, height = NULL, speed = NULL) { pulseOptions <- function(background = NULL, height = NULL, speed = NULL) {
if (is.null(background) && is.null(height) && is.null(speed)) { if (is.null(background) && is.null(height) && is.null(speed)) {
return(NULL) return(NULL)
@@ -244,6 +287,7 @@ busyIndicatorDependency <- function() {
version = get_package_version("shiny"), version = get_package_version("shiny"),
src = "www/shared/busy-indicators", src = "www/shared/busy-indicators",
package = "shiny", package = "shiny",
stylesheet = "busy-indicators.css" stylesheet = "busy-indicators.css",
head = as.character(useBusyIndicators())
) )
} }

View File

@@ -1,2 +1,2 @@
/*! shiny 1.8.1.9001 | (c) 2012-2024 RStudio, PBC. | License: GPL-3 | file LICENSE */ /*! shiny 1.8.1.9001 | (c) 2012-2024 RStudio, PBC. | License: GPL-3 | file LICENSE */
:where([data-shiny-busy-spinners] .recalculating){position:relative}[data-shiny-busy-spinners] .recalculating{opacity:1}[data-shiny-busy-spinners] .recalculating:after{position:absolute;content:"";--_shiny-spinner-url: var(--shiny-spinner-url, url(spinners/ring.svg));--_shiny-spinner-color: var(--shiny-spinner-color, var(--bs-primary, #007bc2));--_shiny-spinner-size: var(--shiny-spinner-size, 32px);--_shiny-spinner-delay: var(--shiny-spinner-delay, 1s);background:var(--_shiny-spinner-color);width:var(--_shiny-spinner-size);height:var(--_shiny-spinner-size);inset:calc(50% - var(--_shiny-spinner-size) / 2);mask-image:var(--_shiny-spinner-url);-webkit-mask-image:var(--_shiny-spinner-url);opacity:0;animation-delay:var(--_shiny-spinner-delay);animation-name:fade-in;animation-duration:.25s;animation-fill-mode:forwards}[data-shiny-busy-spinners] .recalculating>*:not(.recalculating){opacity:.2;transition:opacity .25s ease var(--shiny-spinner-delay, 1s)}[data-shiny-busy-spinners] .recalculating.shiny-html-output:after{display:none}[data-shiny-busy-spinners][data-shiny-busy-pulse].shiny-busy:after{--_shiny-pulse-background: var( --shiny-pulse-background, linear-gradient( 120deg, var(--bs-body-bg, #fff), var(--bs-indigo, #4b00c1), var(--bs-purple, #74149c), var(--bs-pink, #bf007f), var(--bs-body-bg, #fff) ) );--_shiny-pulse-height: var(--shiny-pulse-height, 5px);--_shiny-pulse-speed: var(--shiny-pulse-speed, 1.85s);position:fixed;top:0;left:0;height:var(--_shiny-pulse-height);background:var(--_shiny-pulse-background);z-index:9999;animation-name:busy-page-pulse;animation-duration:var(--_shiny-pulse-speed);animation-iteration-count:infinite;animation-timing-function:ease-in-out;content:""}[data-shiny-busy-spinners][data-shiny-busy-pulse].shiny-busy:has(.recalculating):after{display:none}[data-shiny-busy-spinners][data-shiny-busy-pulse].shiny-busy:has(#shiny-disconnected-overlay):after{display:none}[data-shiny-busy-pulse]:not([data-shiny-busy-spinners]).shiny-busy:after{--_shiny-pulse-background: var( --shiny-pulse-background, linear-gradient( 120deg, var(--bs-body-bg, #fff), var(--bs-indigo, #4b00c1), var(--bs-purple, #74149c), var(--bs-pink, #bf007f), var(--bs-body-bg, #fff) ) );--_shiny-pulse-height: var(--shiny-pulse-height, 5px);--_shiny-pulse-speed: var(--shiny-pulse-speed, 1.85s);position:fixed;top:0;left:0;height:var(--_shiny-pulse-height);background:var(--_shiny-pulse-background);z-index:9999;animation-name:busy-page-pulse;animation-duration:var(--_shiny-pulse-speed);animation-iteration-count:infinite;animation-timing-function:ease-in-out;content:""}[data-shiny-busy-pulse]:not([data-shiny-busy-spinners]).shiny-busy:has(#shiny-disconnected-overlay):after{display:none}@keyframes fade-in{0%{opacity:0}to{opacity:1}}@keyframes busy-page-pulse{0%{left:-75%;width:75%}50%{left:100%;width:75%}to{left:-75%;width:75%}} :where([data-shiny-busy-spinners] .recalculating){position:relative}[data-shiny-busy-spinners] .recalculating:after{position:absolute;content:"";--_shiny-spinner-url: var(--shiny-spinner-url, url(spinners/ring.svg));--_shiny-spinner-color: var(--shiny-spinner-color, var(--bs-primary, #007bc2));--_shiny-spinner-size: var(--shiny-spinner-size, 32px);--_shiny-spinner-delay: var(--shiny-spinner-delay, 1s);background:var(--_shiny-spinner-color);width:var(--_shiny-spinner-size);height:var(--_shiny-spinner-size);inset:calc(50% - var(--_shiny-spinner-size) / 2);mask-image:var(--_shiny-spinner-url);-webkit-mask-image:var(--_shiny-spinner-url);opacity:0;animation-delay:var(--_shiny-spinner-delay);animation-name:fade-in;animation-duration:.25s;animation-fill-mode:forwards}[data-shiny-busy-spinners] .recalculating:has(>*),[data-shiny-busy-spinners] .recalculating:empty{opacity:1}[data-shiny-busy-spinners] .recalculating>*:not(.recalculating){opacity:var(--_shiny-fade-opacity);transition:opacity .25s ease var(--shiny-spinner-delay, 1s)}[data-shiny-busy-spinners] .recalculating.shiny-html-output:after{display:none}[data-shiny-busy-spinners][data-shiny-busy-pulse].shiny-busy:after{--_shiny-pulse-background: var( --shiny-pulse-background, linear-gradient( 120deg, var(--bs-body-bg, #fff), var(--bs-indigo, #4b00c1), var(--bs-purple, #74149c), var(--bs-pink, #bf007f), var(--bs-body-bg, #fff) ) );--_shiny-pulse-height: var(--shiny-pulse-height, 5px);--_shiny-pulse-speed: var(--shiny-pulse-speed, 1.85s);position:fixed;top:0;left:0;height:var(--_shiny-pulse-height);background:var(--_shiny-pulse-background);z-index:9999;animation-name:busy-page-pulse;animation-duration:var(--_shiny-pulse-speed);animation-iteration-count:infinite;animation-timing-function:ease-in-out;content:""}[data-shiny-busy-spinners][data-shiny-busy-pulse].shiny-busy:has(.recalculating):after{display:none}[data-shiny-busy-spinners][data-shiny-busy-pulse].shiny-busy:has(#shiny-disconnected-overlay):after{display:none}[data-shiny-busy-pulse]:not([data-shiny-busy-spinners]).shiny-busy:after{--_shiny-pulse-background: var( --shiny-pulse-background, linear-gradient( 120deg, var(--bs-body-bg, #fff), var(--bs-indigo, #4b00c1), var(--bs-purple, #74149c), var(--bs-pink, #bf007f), var(--bs-body-bg, #fff) ) );--_shiny-pulse-height: var(--shiny-pulse-height, 5px);--_shiny-pulse-speed: var(--shiny-pulse-speed, 1.85s);position:fixed;top:0;left:0;height:var(--_shiny-pulse-height);background:var(--_shiny-pulse-background);z-index:9999;animation-name:busy-page-pulse;animation-duration:var(--_shiny-pulse-speed);animation-iteration-count:infinite;animation-timing-function:ease-in-out;content:""}[data-shiny-busy-pulse]:not([data-shiny-busy-spinners]).shiny-busy:has(#shiny-disconnected-overlay):after{display:none}@keyframes fade-in{0%{opacity:0}to{opacity:1}}@keyframes busy-page-pulse{0%{left:-75%;width:75%}50%{left:100%;width:75%}to{left:-75%;width:75%}}.shiny-spinner-output-container{--shiny-spinner-size: 0px}

File diff suppressed because one or more lines are too long

View File

@@ -44,9 +44,9 @@ div:where(.shiny-html-output) {
/* uiOutput()/ conditionalPanel() are "pass-through" containers when they have children. */ /* uiOutput()/ conditionalPanel() are "pass-through" containers when they have children. */
&:has(> *) { &:has(> *) {
display: contents; display: contents;
/* Pass along styles that no longer impact the pass-through container */ /* Pass along styles that no longer impact the pass-through container */
&.recalculating > * { &.recalculating > * {
opacity: 0.3; opacity: var(--_shiny-fade-opacity);
} }
} }
} }

View File

@@ -156,7 +156,8 @@ html.autoreload-enabled #shiny-disconnected-overlay.reloading {
} }
.recalculating { .recalculating {
opacity: 0.3; --_shiny-fade-opacity: var(--shiny-fade-opacity, 0.3);
opacity: var(--_shiny-fade-opacity);
transition: opacity 250ms ease 500ms; transition: opacity 250ms ease 500ms;
} }

View File

@@ -11,6 +11,8 @@ busyIndicatorOptions(
spinner_size = NULL, spinner_size = NULL,
spinner_delay = NULL, spinner_delay = NULL,
spinner_selector = NULL, spinner_selector = NULL,
fade_opacity = NULL,
fade_selector = NULL,
pulse_background = NULL, pulse_background = NULL,
pulse_height = NULL, pulse_height = NULL,
pulse_speed = NULL pulse_speed = NULL
@@ -45,6 +47,13 @@ if the computation finishes quickly.}
scoping the spinner customization. The default (\code{NULL}) will apply the scoping the spinner customization. The default (\code{NULL}) will apply the
spinner customization to the parent element of the spinner.} spinner customization to the parent element of the spinner.}
\item{fade_opacity}{The opacity (a number between 0 and 1) for recalculating
output. Set to 1 to "disable" the fade.}
\item{fade_selector}{A character string containing a CSS selector for
scoping the spinner customization. The default (\code{NULL}) will apply the
spinner customization to the parent element of the spinner.}
\item{pulse_background}{A CSS background definition for the pulse. The \item{pulse_background}{A CSS background definition for the pulse. The
default uses a default uses a
\href{https://developer.mozilla.org/en-US/docs/Web/CSS/gradient/linear-gradient}{linear-gradient} \href{https://developer.mozilla.org/en-US/docs/Web/CSS/gradient/linear-gradient}{linear-gradient}
@@ -57,11 +66,21 @@ CSS size.}
time.} time.}
} }
\description{ \description{
When busy indicators are enabled (see \code{\link[=useBusyIndicators]{useBusyIndicators()}}), a spinner is Shiny automatically includes busy indicators, which more specifically means:
shown on each calculating/recalculating output, and a pulsing banner is shown \enumerate{
at the top of the page when the app is otherwise busy. This function allows \item Calculating/recalculating outputs have a spinner overlay.
you to customize the appearance of those busy indicators. To apply the \item Outputs fade out/in when recalculating.
customization, include the result of this function inside the app's UI. \item When no outputs are calculating/recalculating, but Shiny is busy
doing something else (e.g., a download, side-effect, etc), a page-level
pulsing banner is shown.
}
This function allows you to customize the appearance of these busy indicators
by including the result of this function inside the app's UI. Note that,
unless \code{spinner_selector} (or \code{fade_selector}) is specified, the spinner/fade
customization applies to the parent element. If the customization should
instead apply to the entire page, set \code{spinner_selector = 'html'} and
\code{fade_selector = 'html'}.
} }
\examples{ \examples{
\dontshow{if (rlang::is_interactive()) (if (getRversion() >= "3.4") withAutoprint else force)(\{ # examplesIf} \dontshow{if (rlang::is_interactive()) (if (getRversion() >= "3.4") withAutoprint else force)(\{ # examplesIf}
@@ -113,5 +132,5 @@ shinyApp(ui, server)
\dontshow{\}) # examplesIf} \dontshow{\}) # examplesIf}
} }
\seealso{ \seealso{
\code{\link[=useBusyIndicators]{useBusyIndicators()}} for enabling/disabling busy indicators. \code{\link[=useBusyIndicators]{useBusyIndicators()}} to disable/enable busy indicators.
} }

View File

@@ -4,7 +4,7 @@
\alias{useBusyIndicators} \alias{useBusyIndicators}
\title{Enable/disable busy indication} \title{Enable/disable busy indication}
\usage{ \usage{
useBusyIndicators(..., spinners = TRUE, pulse = TRUE) useBusyIndicators(..., spinners = TRUE, pulse = TRUE, fade = TRUE)
} }
\arguments{ \arguments{
\item{...}{Currently ignored.} \item{...}{Currently ignored.}
@@ -14,6 +14,9 @@ output.}
\item{pulse}{Whether to show a pulsing banner at the top of the page when the \item{pulse}{Whether to show a pulsing banner at the top of the page when the
app is busy.} app is busy.}
\item{fade}{Whether to fade recalculating outputs. A value of \code{FALSE} is
equivalent to \code{busyIndicatorOptions(fade_opacity=1)}.}
} }
\description{ \description{
Busy indicators provide a visual cue to users when the server is busy Busy indicators provide a visual cue to users when the server is busy

View File

@@ -37,9 +37,11 @@
the spinner. Undo that, but still apply (smaller) opacity to immediate children the spinner. Undo that, but still apply (smaller) opacity to immediate children
that aren't recalculating. that aren't recalculating.
*/ */
opacity: 1; &:has(> *), &:empty {
opacity: 1;
}
> *:not(.recalculating) { > *:not(.recalculating) {
opacity: 0.2; opacity: var(--_shiny-fade-opacity);
transition: opacity 250ms ease var(--shiny-spinner-delay, 1s); transition: opacity 250ms ease var(--shiny-spinner-delay, 1s);
} }
@@ -141,3 +143,12 @@
width: 75%; width: 75%;
} }
} }
// Effectively disable the spinner when it's wrapped in shinycssloader::withSpinner()
// since that's a sign our spinner isn't needed.
// The reason this sets size to 0px instead of display:none is so, if someone
// really wants to show the spinner, they can override this with a custom size.
.shiny-spinner-output-container {
--shiny-spinner-size: 0px;
}