Compare commits

...

3 Commits

Author SHA1 Message Date
Carson
d3ad419ff1 Clear progress for only the bindings that we receive, not any binding that isn't in progress 2024-04-30 18:12:33 -05:00
Carson
13f505934a wip fix shiny.js showProgress logic 2024-04-30 17:59:09 -05:00
Carson
ab9a900fa2 Add useBusyIndicators() 2024-04-30 12:00:02 -05:00
29 changed files with 681 additions and 43 deletions

View File

@@ -128,6 +128,7 @@ Collate:
'map.R'
'utils.R'
'bootstrap.R'
'busy-indicators.R'
'cache-utils.R'
'deprecated.R'
'devmode.R'

View File

@@ -204,6 +204,7 @@ export(pre)
export(prependTab)
export(printError)
export(printStackTrace)
export(pulseOptions)
export(quoToFunction)
export(radioButtons)
export(reactive)
@@ -272,6 +273,7 @@ export(snapshotExclude)
export(snapshotPreprocessInput)
export(snapshotPreprocessOutput)
export(span)
export(spinnerOptions)
export(splitLayout)
export(stopApp)
export(strong)
@@ -317,6 +319,7 @@ export(updateTextInput)
export(updateVarSelectInput)
export(updateVarSelectizeInput)
export(urlModal)
export(useBusyIndicators)
export(validate)
export(validateCssUnit)
export(varSelectInput)

186
R/busy-indicators.R Normal file
View File

@@ -0,0 +1,186 @@
#' Use and customize busy indicator types.
#'
#' To enable busy indicators, include the result of this function in the app's UI.
#'
#' When both `spinners` and `pulse` are set to `TRUE`, the pulse is disabled
#' when spinner(s) are active. When both `spinners` and `pulse` are set to
#' `FALSE`, no busy indication is shown (other than the gray-ing out of
#' recalculating outputs).
#'
#' @param spinners Overlay a spinner on each calculating/recalculating output.
#' @param pulse Show a pulsing banner at the top of the window when the server is busy.
#' @export
#' @seealso [spinnerOptions()] [pulseOptions()]
#' @examplesIf interactive()
#'
#' library(bslib)
#'
#' ui <- page_fillable(
#' useBusyIndicators(),
#' card(
#' card_header(
#' "A plot",
#' input_task_button("simulate", "Simulate"),
#' class = "d-flex justify-content-between align-items-center"
#' ),
#' plotOutput("p"),
#' )
#' )
#'
#' server <- function(input, output) {
#' output$p <- renderPlot({
#' input$simulate
#' Sys.sleep(4)
#' plot(x = rnorm(100), y = rnorm(100))
#' })
#' }
#'
#' shinyApp(ui, server)
useBusyIndicators <- function(spinners = TRUE, pulse = TRUE) {
attrs <- list("shinyBusySpinners" = spinners, "shinyBusyPulse" = pulse)
js <- Map(function(key, value) {
if (value) {
sprintf("document.documentElement.dataset.%s = 'true';", key)
} else {
sprintf("delete document.documentElement.dataset.%s;", key)
}
}, names(attrs), attrs)
js <- HTML(paste(js, collapse = "\n"))
# 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.
tags$script(js)
}
#' Customize spinning busy indicators.
#'
#' Include the result of this function in the app's UI to customize spinner
#' appearance.
#'
#' @details To effectively disable spinners, set the `size` to "0px".
#'
#' @param type The type of spinner to use. Builtin options include: tadpole,
#' disc, dots, dot-track, and bounce. A custom type may also provided, which
#' should be a valid value for the CSS
#' [mask-image](https://developer.mozilla.org/en-US/docs/Web/CSS/mask-image)
#' property.
#' @param color The color of the spinner. This can be any valid CSS color.
#' Defaults to the app's "primary" color (if Bootstrap is on the page) or
#' light-blue if not.
#' @param size The size of the spinner. This can be any valid CSS size.
#' @param easing The easing function to use for the spinner animation. This can
#' be any valid CSS [easing
#' function](https://developer.mozilla.org/en-US/docs/Web/CSS/easing-function).
#' @param speed The amount of time for the spinner to complete a single
#' revolution. This can be any valid CSS time.
#' @param delay The amount of time to wait before showing the spinner. This can
#' be any valid CSS time and can useful for not showing the spinner
#' if the computation finishes quickly.
#' @param css_selector A CSS selector for scoping the spinner customization.
#' Defaults to the root element.
#'
#' @export
#' @seealso [useBusyIndicators()] [pulseOptions()]
#' @examplesIf interactive()
#'
#' library(bslib)
#'
#' ui <- page_fillable(
#' useBusyIndicators(),
#' spinnerOptions(color = "orange"),
#' card(
#' card_header(
#' "A plot",
#' input_task_button("simulate", "Simulate"),
#' class = "d-flex justify-content-between align-items-center"
#' ),
#' plotOutput("p"),
#' )
#' )
#'
#' server <- function(input, output) {
#' output$p <- renderPlot({
#' input$simulate
#' Sys.sleep(4)
#' plot(x = rnorm(100), y = rnorm(100))
#' })
#' }
#'
#' shinyApp(ui, server)
spinnerOptions <- function(
type = NULL,
...,
color = NULL,
size = NULL,
easing = NULL,
speed = NULL,
delay = NULL,
css_selector = ":root"
) {
# bounce requires a different animation than the others
if (isTRUE(type == "bounce")) {
animation <- "shiny-busy-spinner-bounce"
speed <- speed %||% "0.8s"
} else {
animation <- NULL
}
# Supported types have a CSS var already defined with their SVG data
if (isTRUE(type %in% c("tadpole", "disc", "dots", "dot-track", "bounce"))) {
type <- sprintf("var(--_shiny-spinner-type-%s)", type)
}
# Options are controlled via CSS variables.
css_vars <- paste0(c(
if (!is.null(type)) sprintf("--shiny-spinner-mask-img: %s", type),
if (!is.null(easing)) sprintf("--shiny-spinner-easing: %s", easing),
if (!is.null(animation)) sprintf("--shiny-spinner-animation: %s", animation),
if (!is.null(color)) sprintf("--shiny-spinner-color: %s", color),
if (!is.null(size)) sprintf("--shiny-spinner-size: %s", size),
if (!is.null(speed)) sprintf("--shiny-spinner-speed: %s", speed),
if (!is.null(delay)) sprintf("--shiny-spinner-delay: %s", delay)
), collapse = ";")
# The CSS cascade allows this to be called multiple times, and as long as the CSS
# selector is the same, the last call takes precedence. Also, css_selector allows
# for scoping of the spinner customization.
tags$style(HTML(paste0(css_selector, " {", css_vars, "}")))
}
#' Customize the pulsing busy indicator.
#'
#' Include the result of this function in the app's UI to customize the pulsing
#' banner.
#'
#' @param color The color of the pulsing banner. This can be any valid CSS
#' color. Defaults to the app's "primary" color (if Bootstrap is on the page)
#' or light-blue if not.
#' @param height The height of the pulsing banner. This can be any valid CSS
#' size. Defaults to "3.5px".
#' @export
#' @seealso [useBusyIndicators()] [spinnerOptions()]
pulseOptions <- function(color = NULL, height = NULL, speed = NULL) {
css_vars <- paste0(c(
if (!is.null(color)) sprintf("--shiny-pulse-color: %s", color),
if (!is.null(height)) sprintf("--shiny-pulse-height: %s", height),
if (!is.null(speed)) sprintf("--shiny-pulse-speed: %s", speed)
), collapse = ";")
tags$style(HTML(paste0(":root {", css_vars, "}")))
}
busyIndicatorDependency <- function() {
htmlDependency(
name = "shiny-busy-indicators",
version = get_package_version("shiny"),
src = "www/shared/busy-indicators",
package = "shiny",
stylesheet = "busy-indicators.css",
script = "busy-indicators.js"
)
}

View File

@@ -114,6 +114,7 @@ jqueryDependency <- function() {
shinyDependencies <- function() {
list(
bslib::bs_dependency_defer(shinyDependencyCSS),
busyIndicatorDependency(),
htmlDependency(
name = "shiny-javascript",
version = get_package_version("shiny"),

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,3 @@
/*! shiny 1.8.1.9000 | (c) 2012-2024 RStudio, PBC. | License: GPL-3 | file LICENSE */
"use strict";(function(){document.documentElement.classList.add("shiny-not-yet-idle");$(document).one("shiny:idle",function(){document.documentElement.classList.remove("shiny-not-yet-idle")});})();
//# sourceMappingURL=busy-indicators.js.map

View File

@@ -0,0 +1,7 @@
{
"version": 3,
"sources": ["../../../../srcts/extras/busy-indicators/busy-indicators.ts"],
"sourcesContent": ["// This of this like the .shiny-busy class that shiny.js puts on the root element,\n// except it's added before shiny.js is initialized, connected, etc.\n// TODO: maybe shiny.js should be doing something like this?\ndocument.documentElement.classList.add(\"shiny-not-yet-idle\");\n$(document).one(\"shiny:idle\", function () {\n document.documentElement.classList.remove(\"shiny-not-yet-idle\");\n});"],
"mappings": ";yBAGA,SAAS,gBAAgB,UAAU,IAAI,oBAAoB,EAC3D,EAAE,QAAQ,EAAE,IAAI,aAAc,UAAY,CACxC,SAAS,gBAAgB,UAAU,OAAO,oBAAoB,CAChE,CAAC",
"names": []
}

View File

@@ -12057,6 +12057,13 @@
value: function renderValue(el, data) {
(0, import_jquery23.default)(el).attr("href", data);
}
}, {
key: "showProgress",
value: function showProgress(el, show3) {
return;
el;
show3;
}
}]);
return DownloadLinkOutputBinding2;
}(OutputBinding);
@@ -18698,7 +18705,7 @@
}
function _bindOutputs() {
_bindOutputs = _asyncToGenerator9(/* @__PURE__ */ _regeneratorRuntime9().mark(function _callee(_ref3) {
var sendOutputHiddenState, maybeAddThemeObserver, outputBindings, scope, $scope, bindings, i5, binding, matches, j3, _el2, id, $el, bindingAdapter, _args = arguments;
var sendOutputHiddenState, maybeAddThemeObserver, outputBindings, scope, $scope, bindings, i5, binding, matches, j3, _Shiny$shinyapp, _el2, id, $el, bindingAdapter, _args = arguments;
return _regeneratorRuntime9().wrap(function _callee$(_context) {
while (1)
switch (_context.prev = _context.next) {
@@ -18710,7 +18717,7 @@
i5 = 0;
case 5:
if (!(i5 < bindings.length)) {
_context.next = 34;
_context.next = 35;
break;
}
binding = bindings[i5].binding;
@@ -18718,7 +18725,7 @@
j3 = 0;
case 9:
if (!(j3 < matches.length)) {
_context.next = 31;
_context.next = 32;
break;
}
_el2 = matches[j3];
@@ -18727,20 +18734,20 @@
_context.next = 14;
break;
}
return _context.abrupt("continue", 28);
return _context.abrupt("continue", 29);
case 14:
if (import_jquery37.default.contains(document.documentElement, _el2)) {
_context.next = 16;
break;
}
return _context.abrupt("continue", 28);
return _context.abrupt("continue", 29);
case 16:
$el = (0, import_jquery37.default)(_el2);
if (!$el.hasClass("shiny-bound-output")) {
_context.next = 19;
break;
}
return _context.abrupt("continue", 28);
return _context.abrupt("continue", 29);
case 19:
maybeAddThemeObserver(_el2);
bindingAdapter = new OutputBindingAdapter(_el2, binding);
@@ -18749,6 +18756,10 @@
case 23:
$el.data("shiny-output-binding", bindingAdapter);
$el.addClass("shiny-bound-output");
if ((_Shiny$shinyapp = Shiny.shinyapp) !== null && _Shiny$shinyapp !== void 0 && _Shiny$shinyapp.isRecalculating(id)) {
if (binding.showProgress)
binding.showProgress(_el2, true);
}
if (!$el.attr("aria-live"))
$el.attr("aria-live", "polite");
bindingsRegistry.addBinding(id, "output");
@@ -18757,18 +18768,18 @@
binding: binding,
bindingType: "output"
});
case 28:
case 29:
j3++;
_context.next = 9;
break;
case 31:
case 32:
i5++;
_context.next = 5;
break;
case 34:
case 35:
setTimeout(sendImageSizeFns.regular, 0);
setTimeout(sendOutputHiddenState, 0);
case 36:
case 37:
case "end":
return _context.stop();
}
@@ -22844,6 +22855,7 @@
_defineProperty20(this, "$persistentProgress", /* @__PURE__ */ new Set());
_defineProperty20(this, "$values", {});
_defineProperty20(this, "$errors", {});
_defineProperty20(this, "$invalidated", /* @__PURE__ */ new Set());
_defineProperty20(this, "$conditionals", {});
_defineProperty20(this, "$pendingMessages", []);
_defineProperty20(this, "$activeRequests", {});
@@ -22871,6 +22883,7 @@
binding: function binding(message) {
var key = message.id;
var binding2 = this.$bindings[key];
this.$invalidated.add(key);
if (binding2) {
(0, import_jquery38.default)(binding2.el).trigger({
type: "shiny:outputinvalidated",
@@ -23485,13 +23498,17 @@
}()
}, {
key: "_clearProgress",
value: function _clearProgress() {
for (var name in this.$bindings) {
if (hasOwnProperty(this.$bindings, name) && !this.$persistentProgress.has(name)) {
this.$bindings[name].showProgress(false);
}
value: function _clearProgress(name) {
if (hasOwnProperty(this.$bindings, name) && !this.$persistentProgress.has(name)) {
this.$bindings[name].showProgress(false);
}
}
}, {
key: "isRecalculating",
value: function isRecalculating(name) {
var hasResult = hasOwnProperty(this.$values, name) || hasOwnProperty(this.$errors, name);
return this.$invalidated.has(name) || !hasResult;
}
}, {
key: "_init",
value: function _init() {
@@ -23503,25 +23520,26 @@
while (1)
switch (_context8.prev = _context8.next) {
case 0:
_this3._clearProgress();
_context8.t0 = _regeneratorRuntime13().keys(message);
case 2:
case 1:
if ((_context8.t1 = _context8.t0()).done) {
_context8.next = 10;
_context8.next = 11;
break;
}
_key = _context8.t1.value;
if (!hasOwnProperty(message, _key)) {
_context8.next = 8;
_context8.next = 9;
break;
}
_this3._clearProgress(_key);
_this3.$persistentProgress.delete(_key);
_context8.next = 8;
_this3.$invalidated.delete(_key);
_context8.next = 9;
return _this3.receiveOutput(_key, message[_key]);
case 8:
_context8.next = 2;
case 9:
_context8.next = 1;
break;
case 10:
case 11:
case "end":
return _context8.stop();
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

23
man/pulseOptions.Rd Normal file
View File

@@ -0,0 +1,23 @@
% Generated by roxygen2: do not edit by hand
% Please edit documentation in R/busy-indicators.R
\name{pulseOptions}
\alias{pulseOptions}
\title{Customize the pulsing busy indicator.}
\usage{
pulseOptions(color = NULL, height = NULL, speed = NULL)
}
\arguments{
\item{color}{The color of the pulsing banner. This can be any valid CSS
color. Defaults to the app's "primary" color (if Bootstrap is on the page)
or light-blue if not.}
\item{height}{The height of the pulsing banner. This can be any valid CSS
size. Defaults to "3.5px".}
}
\description{
Include the result of this function in the app's UI to customize the pulsing
banner.
}
\seealso{
\code{\link[=useBusyIndicators]{useBusyIndicators()}} \code{\link[=spinnerOptions]{spinnerOptions()}}
}

82
man/spinnerOptions.Rd Normal file
View File

@@ -0,0 +1,82 @@
% Generated by roxygen2: do not edit by hand
% Please edit documentation in R/busy-indicators.R
\name{spinnerOptions}
\alias{spinnerOptions}
\title{Customize spinning busy indicators.}
\usage{
spinnerOptions(
type = NULL,
...,
color = NULL,
size = NULL,
easing = NULL,
speed = NULL,
delay = NULL,
css_selector = ":root"
)
}
\arguments{
\item{type}{The type of spinner to use. Builtin options include: tadpole,
disc, dots, dot-track, and bounce. A custom type may also provided, which
should be a valid value for the CSS
\href{https://developer.mozilla.org/en-US/docs/Web/CSS/mask-image}{mask-image}
property.}
\item{color}{The color of the spinner. This can be any valid CSS color.
Defaults to the app's "primary" color (if Bootstrap is on the page) or
light-blue if not.}
\item{size}{The size of the spinner. This can be any valid CSS size.}
\item{easing}{The easing function to use for the spinner animation. This can
be any valid CSS \href{https://developer.mozilla.org/en-US/docs/Web/CSS/easing-function}{easing function}.}
\item{speed}{The amount of time for the spinner to complete a single
revolution. This can be any valid CSS time.}
\item{delay}{The amount of time to wait before showing the spinner. This can
be any valid CSS time and can useful for not showing the spinner
if the computation finishes quickly.}
\item{css_selector}{A CSS selector for scoping the spinner customization.
Defaults to the root element.}
}
\description{
Include the result of this function in the app's UI to customize spinner
appearance.
}
\details{
To effectively disable spinners, set the \code{size} to "0px".
}
\examples{
\dontshow{if (interactive()) (if (getRversion() >= "3.4") withAutoprint else force)(\{ # examplesIf}
library(bslib)
ui <- page_fillable(
useBusyIndicators(),
spinnerOptions(color = "orange"),
card(
card_header(
"A plot",
input_task_button("simulate", "Simulate"),
class = "d-flex justify-content-between align-items-center"
),
plotOutput("p"),
)
)
server <- function(input, output) {
output$p <- renderPlot({
input$simulate
Sys.sleep(4)
plot(x = rnorm(100), y = rnorm(100))
})
}
shinyApp(ui, server)
\dontshow{\}) # examplesIf}
}
\seealso{
\code{\link[=useBusyIndicators]{useBusyIndicators()}} \code{\link[=pulseOptions]{pulseOptions()}}
}

53
man/useBusyIndicators.Rd Normal file
View File

@@ -0,0 +1,53 @@
% Generated by roxygen2: do not edit by hand
% Please edit documentation in R/busy-indicators.R
\name{useBusyIndicators}
\alias{useBusyIndicators}
\title{Use and customize busy indicator types.}
\usage{
useBusyIndicators(spinners = TRUE, pulse = TRUE)
}
\arguments{
\item{spinners}{Overlay a spinner on each calculating/recalculating output.}
\item{pulse}{Show a pulsing banner at the top of the window when the server is busy.}
}
\description{
To enable busy indicators, include the result of this function in the app's UI.
}
\details{
When both \code{spinners} and \code{pulse} are set to \code{TRUE}, the pulse is disabled
when spinner(s) are active. When both \code{spinners} and \code{pulse} are set to
\code{FALSE}, no busy indication is shown (other than the gray-ing out of
recalculating outputs).
}
\examples{
\dontshow{if (interactive()) (if (getRversion() >= "3.4") withAutoprint else force)(\{ # examplesIf}
library(bslib)
ui <- page_fillable(
useBusyIndicators(),
card(
card_header(
"A plot",
input_task_button("simulate", "Simulate"),
class = "d-flex justify-content-between align-items-center"
),
plotOutput("p"),
)
)
server <- function(input, output) {
output$p <- renderPlot({
input$simulate
Sys.sleep(4)
plot(x = rnorm(100), y = rnorm(100))
})
}
shinyApp(ui, server)
\dontshow{\}) # examplesIf}
}
\seealso{
\code{\link[=spinnerOptions]{spinnerOptions()}} \code{\link[=pulseOptions]{pulseOptions()}}
}

View File

@@ -53,7 +53,7 @@
"esbuild": "^0.15.10",
"esbuild-plugin-babel": "https://github.com/schloerke/esbuild-plugin-babel#patch-2",
"esbuild-plugin-globals": "^0.1.1",
"esbuild-plugin-sass": "^1.0.1",
"esbuild-sass-plugin": "^2.9.0",
"eslint": "^8.24.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-jest": "^27.0.4",

View File

@@ -17,6 +17,7 @@ build({
"srcts/extras/shiny-autoreload.ts",
"srcts/extras/shiny-showcase.ts",
"srcts/extras/shiny-testmode.ts",
"srcts/extras/busy-indicators/busy-indicators.ts",
],
outdir: outDir,
});
@@ -24,7 +25,7 @@ build({
// - Sass -----------------------------------------------------------
import autoprefixer from "autoprefixer";
import sassPlugin from "esbuild-plugin-sass";
import sassPlugin from "esbuild-sass-plugin";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore; Type definitions are not found. This occurs when `strict: true` in tsconfig.json
import postCssPlugin from "@deanc/esbuild-plugin-postcss";
@@ -53,3 +54,12 @@ build({
],
outfile: outDir + "shiny.min.css",
});
build({
...sassOpts,
entryPoints: ["srcts/extras/busy-indicators/busy-indicators.scss"],
outfile: outDir + "busy-indicators/busy-indicators.css",
plugins: [sassPlugin({ type: "css", sourceMap: false })],
loader: { ".svg": "dataurl" },
bundle: true,
metafile: true,
});

View File

@@ -0,0 +1,3 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<ellipse class="spinner_rXNP" cx="12" cy="5" rx="4" ry="4" />
</svg>

After

Width:  |  Height:  |  Size: 132 B

View File

@@ -0,0 +1,193 @@
/*
"Built-in" spinner types
It's important to list out all the types/svgs here so that esbuild can bundle them
with the CSS (see js/build.ts).
*/
:root {
--_shiny-spinner-type-tadpole: url(tadpole-spinner.svg);
--_shiny-spinner-type-disc: url(disc-spinner.svg);
--_shiny-spinner-type-dots: url(dots-spinner.svg);
--_shiny-spinner-type-dot-track: url(dot-track-spinner.svg);
--_shiny-spinner-type-bounce: url(ball.svg);
}
/* This data atttribute is set by ui.busy_indicators.use() */
[data-shiny-busy-spinners] {
/* This class gets set by busy_indicators.ts */
.recalculating {
position: relative;
overflow: visible; /* overflow:hidden can, in some cases, clip the spinner */
&::after {
position: absolute;
/* ui.busy_indicators.spinner_options() */
--_shiny-spinner-mask-img: var(--shiny-spinner-mask-img, var(--_shiny-spinner-type-tadpole));
--_shiny-spinner-color: var(--shiny-spinner-color, var(--bs-primary, #007bc2));
--_shiny-spinner-size: var(--shiny-spinner-size, 32px);
--_shiny-spinner-easing: var(--shiny-spinner-easing, ease-in-out);
--_shiny-spinner-speed: var(--shiny-spinner-speed, 2s);
--_shiny-spinner-delay: var(--shiny-spinner-delay, 0.5s);
--_shiny-spinner-animation: var(--shiny-spinner-animation, shiny-busy-spinner-spin);
/* Apply the spinner type as a mask */
mask-image: var(--_shiny-spinner-mask-img);
mask-position: center;
mask-size: contain;
mask-repeat: no-repeat;
-webkit-mask-image: var(--_shiny-spinner-mask-img);
-webkit-mask-position: center;
-webkit-mask-size: contain;
-webkit-mask-repeat: no-repeat;
/* Color, sizing, & positioning */
background: var(--_shiny-spinner-color);
width: var(--_shiny-spinner-size);
height: var(--_shiny-spinner-size);
inset: calc(50% - var(--_shiny-spinner-size) / 2);
/* Animation */
animation-name: var(--_shiny-spinner-animation);
animation-duration: var(--_shiny-spinner-speed);
animation-iteration-count: infinite;
animation-timing-function: var(--_shiny-spinner-easing);
animation-delay: var(--_shiny-spinner-delay);
content: "";
scale: 0;
}
/*
shiny.css puts `opacity: 0.3` on .recalculating, which unfortunately applies to
the spinner. Undo that, but still apply (smaller) opacity to immediate children
that aren't shiny-output-busy.
*/
opacity: 1;
> *:not(.recalculating) {
opacity: 0.2;
}
/*
Disable spinner on uiOutput() mainly because (for other reasons) it has
`display:contents`, which breaks the ::after positioning.
Note that, even if we could position it, we'd probably want to disable it
if it has shiny-output-busy children.
*/
&.shiny-html-output::after {
display: none;
}
}
}
/* Styles for the page-level pulse banner */
@mixin shiny-page-busy {
/* ui.busy_indicators.pulse_options() */
--_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);
/* Color, sizing, & positioning */
position: fixed;
top: 0;
left: 0;
height: var(--_shiny-pulse-height);
background: var(--_shiny-pulse-background);
border-radius: 50%;
z-index: 9999;
/* Animation */
animation-name: busy-page-pulse;
animation-duration: var(--_shiny-pulse-speed);
animation-iteration-count: infinite;
animation-timing-function: ease-in-out;
content: ""; /* Used in a ::after context */
}
/*
In spinners+pulse mode (the recommended default), show a page-level banner if the
page is busy, but there are no shiny-output-busy elements.
*/
[data-shiny-busy-spinners][data-shiny-busy-pulse] {
&.shiny-busy:not(:has(.recalculating))::after {
@include shiny-page-busy;
}
&.shiny-not-yet-idle:not(:has(.recalculating))::after {
@include shiny-page-busy;
}
}
/* In pulse _only_ mode, show a page-level banner whenever shiny is busy. */
[data-shiny-busy-pulse]:not([data-shiny-busy-spinners]) {
&.shiny-busy::after {
@include shiny-page-busy;
}
&.shiny-not-yet-idle::after {
@include shiny-page-busy;
}
}
/* Keyframes behind most spinner types */
@keyframes shiny-busy-spinner-spin {
0% {
scale: 1;
rotate: 0deg;
}
100% {
scale: 1;
rotate: 360deg;
}
}
/* For busy_indicators.spinner_options(type="bounce") */
@keyframes shiny-busy-spinner-bounce {
0% {
animation-timing-function: cubic-bezier(0.33, 0, 0.66, 0.33);
translate: 0 calc(var(--_shiny-spinner-size) * (5 / 24));
scale: 1 1;
}
46.875% {
translate: 0 calc(var(--_shiny-spinner-size) * (20 / 24));
scale: 1 1;
}
50% {
animation-timing-function: cubic-bezier(0.33, 0.66, 0.66, 1);
translate: 0 calc(var(--_shiny-spinner-size) * (20.5 / 24));
scale: 1.2 0.85;
}
53.125% {
scale: 1 1;
}
100% {
translate: 0 calc(var(--_shiny-spinner-size) * (5 / 24));
scale: 1 1;
}
}
/* Keyframes for the pulsing banner */
@keyframes busy-page-pulse {
0% {
left: -75%;
width: 75%;
}
50% {
left: 100%;
width: 75%;
}
/* Go back */
100% {
left: -75%;
width: 75%;
}
}

View File

@@ -0,0 +1,7 @@
// This of this like the .shiny-busy class that shiny.js puts on the root element,
// except it's added before shiny.js is initialized, connected, etc.
// TODO: maybe shiny.js should be doing something like this?
document.documentElement.classList.add("shiny-not-yet-idle");
$(document).one("shiny:idle", function () {
document.documentElement.classList.remove("shiny-not-yet-idle");
});

View File

@@ -0,0 +1,5 @@
<svg viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M9 16.7833C13.2986 16.7833 17.3579 13.2986 17.3579 9C17.3579 4.70142 13.2986 1.21672 9 1.21672C4.70141 1.21672 1.21672 4.70142 1.21672 9C1.21672 13.2986 4.70141 16.7833 9 16.7833ZM9 17.8678C13.8976 17.8678 17.3579 13.8976 17.3579 9C17.3579 4.10245 13.8976 0.132202 9 0.132202C4.10245 0.132202 0.132198 4.10245 0.132198 9C0.132198 13.8976 4.10245 17.8678 9 17.8678Z"
fill="black" />
</svg>

After

Width:  |  Height:  |  Size: 521 B

View File

@@ -0,0 +1,5 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
opacity=".25" />
<circle class="spinner_7WDj" cx="12" cy="2.5" r="1.5" />
</svg>

After

Width:  |  Height:  |  Size: 238 B

View File

@@ -0,0 +1,11 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<g class="spinner_Wezc">
<circle cx="12" cy="2.5" r="1.5" opacity=".14" />
<circle cx="16.75" cy="3.77" r="1.5" opacity=".29" />
<circle cx="20.23" cy="7.25" r="1.5" opacity=".43" />
<circle cx="21.50" cy="12.00" r="1.5" opacity=".57" />
<circle cx="20.23" cy="16.75" r="1.5" opacity=".71" />
<circle cx="16.75" cy="20.23" r="1.5" opacity=".86" />
<circle cx="12" cy="21.5" r="1.5" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 522 B

View File

@@ -0,0 +1,5 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path class="spinner_0XTQ"
d="M12,23a9.63,9.63,0,0,1-8-9.5,9.51,9.51,0,0,1,6.79-9.1A1.66,1.66,0,0,0,12,2.81h0a1.67,1.67,0,0,0-1.94-1.64A11,11,0,0,0,12,23Z" />
</svg>

After

Width:  |  Height:  |  Size: 234 B

View File

@@ -9,6 +9,13 @@ class DownloadLinkOutputBinding extends OutputBinding {
renderValue(el: HTMLElement, data: string): void {
$(el).attr("href", data);
}
// Progress shouldn't be shown on the download button
// (progress will be shown as a page level pulse instead)
showProgress(el: HTMLElement, show: boolean): void {
return;
el;
show;
}
}
interface FileDownloadEvent extends JQuery.Event {

View File

@@ -318,6 +318,9 @@ async function bindOutputs(
await shinyAppBindOutput(id, bindingAdapter);
$el.data("shiny-output-binding", bindingAdapter);
$el.addClass("shiny-bound-output");
if (Shiny.shinyapp?.isRecalculating(id)) {
if (binding.showProgress) binding.showProgress(el, true);
}
if (!$el.attr("aria-live")) $el.attr("aria-live", "polite");
bindingsRegistry.addBinding(id, "output");

View File

@@ -135,6 +135,7 @@ class ShinyApp {
// Cached values/errors
$values: { [key: string]: any } = {};
$errors: { [key: string]: ErrorsMessageValue } = {};
$invalidated: Set<string> = new Set();
// Conditional bindings (show/hide element based on expression)
$conditionals = {};
@@ -687,28 +688,32 @@ class ShinyApp {
}
}
private _clearProgress() {
for (const name in this.$bindings) {
if (
hasOwnProperty(this.$bindings, name) &&
!this.$persistentProgress.has(name)
) {
this.$bindings[name].showProgress(false);
}
private _clearProgress(name: string) {
if (
hasOwnProperty(this.$bindings, name) &&
!this.$persistentProgress.has(name)
) {
this.$bindings[name].showProgress(false);
}
}
isRecalculating(name: string): boolean {
const hasResult =
hasOwnProperty(this.$values, name) || hasOwnProperty(this.$errors, name);
return this.$invalidated.has(name) || !hasResult;
}
private _init() {
// Dev note:
// * Use arrow functions to allow the Types to propagate.
// * However, `_sendMessagesToHandlers()` will adjust the `this` context to the same _`this`_.
addMessageHandler("values", async (message: { [key: string]: any }) => {
this._clearProgress();
for (const key in message) {
if (hasOwnProperty(message, key)) {
this._clearProgress(key);
this.$persistentProgress.delete(key);
this.$invalidated.delete(key);
await this.receiveOutput(key, message[key]);
}
}
@@ -1418,6 +1423,8 @@ class ShinyApp {
const key = message.id;
const binding = this.$bindings[key];
this.$invalidated.add(key);
if (binding) {
$(binding.el).trigger({
type: "shiny:outputinvalidated",

View File

@@ -2,5 +2,6 @@ import { OutputBinding } from "./outputBinding";
declare class DownloadLinkOutputBinding extends OutputBinding {
find(scope: HTMLElement): JQuery<HTMLElement>;
renderValue(el: HTMLElement, data: string): void;
showProgress(el: HTMLElement, show: boolean): void;
}
export { DownloadLinkOutputBinding };

View File

@@ -37,6 +37,7 @@ declare class ShinyApp {
$errors: {
[key: string]: ErrorsMessageValue;
};
$invalidated: Set<string>;
$conditionals: {};
$pendingMessages: MessageValue[];
$activeRequests: {
@@ -76,6 +77,7 @@ declare class ShinyApp {
dispatchMessage(data: ArrayBufferLike | string): Promise<void>;
private _sendMessagesToHandlers;
private _clearProgress;
isRecalculating(name: string): boolean;
private _init;
progressHandlers: {
binding: (this: ShinyApp, message: {