19 Adding more interactivity
In this part, we are going to bring even more life to the template element. We first see how to enhance an existing static HTML component, through a simple progress bar example. Then we explore more complex elements involving specific Shiny patterns. By the end of the chapter, you will be able to implement components like the one showed in Figure 19.1.
All the JavaScript handlers described below are gathered in an HTML dependency, as well as an input binding(s):
# contains bindings and other JS code
tabler_custom_js <- htmlDependency(
name = "tabler-custom-js",
version = "1.0.7",
src = "tabler",
package = "OSUICode",
script = c(
"tabler-update-navbar/navbarMenuBinding.js",
"handlers/tabler_progress_handler.js",
"handlers/tabler_toast_handler.js",
"handlers/tabler_dropdown_handler.js",
"handlers/tabler_insert_tab_handler.js"
)
)
19.1 Custom progress bars
Progress bars are a good way to display metrics related to a progress, for instance tracking the number of remaining tasks for a project. In general, those elements are static HTML. Hence, it would be interesting to update the current value from the server side. Since it is not an proper input element, implementing an input binding is inappropriate, and we decide to proceed with a custom handler. We first create the tabler_progress()
tag, which is mainly composed of:
-
style
gives the current progress value. This is the main element. -
min
andmax
are bounds, in general between 0 and 100. -
id
ensures the progress bar uniqueness, thereby avoiding conflicts. -
aria-...
parameters are accessibility elements.
tabler_progress <- function(id = NULL, value) {
div(
class = "progress",
div(
id = id,
class = "progress-bar",
style = paste0("width: ", value, "%"),
role = "progressbar",
`aria-valuenow` = as.character(value),
`aria-valuemin` = "0",
`aria-valuemax` = "100",
span(class = "sr-only", paste0(value,"% complete"))
)
)
}
The next element is the update_tabler_progress()
function, which sends two elements from R to JS.
How to handle custom messages in Shiny modules? Well, it is pretty straightforward. We wrap any id with the module namespace given by session$ns()
before sending it to JS. You may even do it by default since session$ns()
returns ""
.
- The progress id wrapped in
session$ns
. - The new value.
update_tabler_progress <- function(
id,
value,
session = shiny::getDefaultReactiveDomain()
) {
message <- list(id = session$ns(id), value = value)
session$sendCustomMessage(
type = "update-progress",
message
)
}
On the JS side, we leverage the well-known Shiny.addCustomMessageHandler
. As mentioned in Chapter 10, sendCustomMessage
and addCustomMessageHandler
are connected by the type parameter. This is crucial! Moreover, as the sent message is a R list, it becomes an JSON, meaning that elements must be accessed with a .
in JS:
$(function () {
.addCustomMessageHandler(
Shiny'update-progress', function(message) {
$('#' + message.id).css('width', message.value +'%');
;
}); })
We finally test these components in a simple app, whose output is depicted in Figure 19.2:
### RUN ###
# OSUICode::run_example(
# "tabler/update-progress",
# package = "OSUICode"
# )
### APP CODE ###
library(shiny)
library(shinyWidgets)
library(OSUICode)
ui <- tabler_page(
tabler_body(
noUiSliderInput(
inputId = "progress_value",
label = "Progress value",
min = 0,
max = 100,
value = 20
),
tabler_progress(id = "progress1", 12)
)
)
server <- function(input, output, session) {
observeEvent(input$progress_value, {
update_tabler_progress(
id = "progress1",
input$progress_value
)
})
}
shinyApp(ui, server)
Is there a way to directly update the progress from the client that would avoid to exchange data between R and JS, thereby saving some time?
The idea is to get rid of the classic session$sendCustomMessage
and Shiny.addCustomMessageHandler
method. We could directly create a function that inserts a script in the UI taking a trigger and target as main parameters. This function would have to be inserted multiple times if multiple triggers had to update the same target. The JS logic is slightly different:
- We have to wait for Shiny to be connected so that the JS
Shiny
object is ready. - We recover the trigger element with any JS/jQuery method.
- We leverage the noUiSlider API to listen to any update in the range. It’s fine because the slider instance has already been initialized in the shinyWidget input binding. This would not work if we were not waiting for Shiny to be connected (you may try)! Notice the use of
this.get()
in the event listener, which avoids repeatingslider.noUiSlider
- We modify the
width
CSS property of the target as in the previous example.
$(document).on('shiny:connected', function(event) {
let slider = document.getElementById('triggerId');
.noUiSlider.on('update', function(event) {
slider$('#targetId').css('width', this.get() + '%');
;
}); })
To insert this script in the app UI, we create update_tabler_progress2
that just wraps it inside a tags$script
.
update_tabler_progress2 <- function(trigger, target) {
tags$script(
paste0(
"$(document).on('shiny:connected', function(event) {
let slider = document.getElementById('", trigger, "');
slider.noUiSlider.on('update', function(event) {
$('#", target, "').css('width', this.get() + '%');
});
});
"
)
)
}
### RUN ###
# OSUICode::run_example(
# "tabler/update-progress-2",
# package = "OSUICode"
# )
### APP CODE ###
library(shiny)
library(OSUICode)
ui <- tabler_page(
update_tabler_progress2("progress_value", "progress1"),
update_tabler_progress2("progress_value2", "progress2"),
tabler_body(
fluidRow(
noUiSliderInput(
inputId = "progress_value",
label = "Progress value 1",
min = 0,
max = 100,
value = 20
),
noUiSliderInput(
inputId = "progress_value2",
label = "Progress value 2",
min = 0,
max = 100,
value = 80,
color = "red"
)
),
tabler_progress(id = "progress1", 12),
br(), br(),
tabler_progress(id = "progress2", 100)
)
)
server <- function(input, output, session) {}
shinyApp(ui, server)
Question: Run the above example in an external web browser, then stop the app from RStudio. Try to move both sliders. What happens for the progress bars? Compare with a classic update
function. How could you explain this? Answer: as we are not sending a message through the websocket with session$sendCustomMessage
, the progress bar still updates even though the websocket connection is closed.
Overall this way is a bit more complex. Yet, assuming an app with data manipulation, tons of inputs and visualizations, everything that can be done from the client (web browser) is less work for the R server part and a better end-user experience! Building outstanding Shiny apps is not only designing amazing user interfaces, it’s also about optimization and speed as mentioned by Colin Fay et al. in their book (Fay et al. 2020).
19.2 User feedback: toasts
Toasts are components to send discrete user feedback, contrary to modals which open in the middle of the page. Toasts may open on all sides of the window and are similar to the Shiny notifications (see here). The Tabler toast component is built on top of Bootstrap 4. Therefore, we rely on this documentation.
19.2.1 Toast skeleton
The skeleton is the HTML structure of the toast:
<div class="toast show" role="alert" aria-live="assertive"
aria-atomic="true" data-autohide="false" data-toggle="toast">
<div class="toast-header">
<span class="avatar mr-2"
style="background-image: url(...)"></span>
<strong class="mr-auto">Mallory Hulme</strong>
<small>11 mins ago</small>
<button type="button" class="ml-2 close"
data-dismiss="toast" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
</div>
<div class="toast-body">
Hello, world! This is a toast message.</div>
</div>
Toasts are mainly composed of a header and a body. There might be a close button in case the toast does not hide itself after a period of time. If multiple toasts appear one after each others, they are stacked, the latest being at the bottom of the stack. The position is controlled with the style attribute like style="position: absolute; top: 0; right: 0;"
for a top-right placement. Accessibility parameters like aria-live
are detailed here.
19.2.2 The toast API
Toasts have a JS API to control their behavior, for instance $('<toast_selector>').toast(option)
, where option is a JSON with the following fields:
- animation applies a CSS fade transition to the toast and is TRUE by default.
- autohide automatically hides the toast (TRUE by default).
- delay is the delay to hide the toast (500 ms).
There are three methods: hide, show and dispose (dispose ensures the toast does not appear anymore). Finally, we may fine-tune the toast behavior with four events: show.bs.toast
, shown.bs.toast
, hide.bs.toast
, hidden.bs.toast
(like for tabs).
19.2.3 R implementation
We first create the toast skeleton in a tabler_toast()
function. We assume our toast will eventually hide automatically, so we may remove the delete button as well as the data-autohide="false
attribute. All parameters are optional except the toast id, which is required to toggle the toast:
tabler_toast <- function(id, title = NULL, subtitle = NULL,
..., img = NULL) {
# SEE BELOW
}
The first part is the toast header, which is a direct translation of the above HTML structure. Notice how we handle optional parameters with if (!is.null(<PARAM>))
so that the tag is not included if no value is given:
toast_header <- div(
class = "toast-header",
if (!is.null(img)) {
span(
class = "avatar mr-2",
style = sprintf("background-image: url(%s)", img)
)
},
if (!is.null(title)) strong(class = "mr-2", title),
if (!is.null(subtitle)) tags$small(subtitle)
)
The body is the simplest part, it receives the main content in ...
:
toast_body <- div(class = "toast-body", ...)
The wrapper is the external toast tag that will received both body and header elements, owing to tagAppendChildren()
:
toast_wrapper <- div(
id = id,
class = "toast",
role = "alert",
style = "position: absolute; top: 0; right: 0;",
`aria-live` = "assertive",
`aria-atomic` = "true",
`data-toggle` = "toast"
)
tagAppendChildren(toast_wrapper, toast_header, toast_body)
The whole code may be found in the {OSUICode}
package (see https://github.com/DivadNojnarg/outstanding-shiny-ui-code/blob/101248eabda2bd2682c73c2998dbe7d53cb5eb78/R/tabler.R#L820).
We create the show_tabler_toast()
function. Since the toast automatically hides, it does not make sense to create the hide function, as well as the dispose:
show_tabler_toast <- function(
id,
options = NULL,
session = getDefaultReactiveDomain()
) {
message <- dropNulls(
list(
id = session$ns(id),
options = options
)
)
session$sendCustomMessage(type = "tabler-toast", message)
}
Let’s design the corresponding JS handler. We first configure the toast and show it. Notice how we chained jQuery methods (see Chapter 10). We optionally add an event listener to capture the hidden.bs.toast
event, so that we may trigger an action when the toast is closed. The input$id
is used for that purpose in combination with the Shiny.setInputValue
. Notice the extra parameter {priority: 'event'}
: basically, once the toast is closed, input$id
is always TRUE
, thereby breaking the reactivity. Adding this extra parameter forces the evaluation of the input, although constant over time.
$(function() {
.addCustomMessageHandler(
Shiny'tabler-toast', function(message) {
$(`#${message.id}`)
.toast(message.options)
.toast('show');
// add custom Shiny input to listen to the toast state
$(`#${message.id}`).one('hidden.bs.toast', function() {
.setInputValue(
Shiny.id,
messagetrue,
priority: 'event'}
{;
);
});
}); })
### RUN ###
# OSUICode::run_example(
# "tabler/toast",
# package = "OSUICode"
# )
### APP CODE ###
library(shiny)
library(OSUICode)
ui <- tabler_page(
tabler_toast(
id = "toast",
title = "Hello",
subtitle = "now",
"Toast body",
img = "https://preview-dev.tabler.io/static/logo.svg"
),
tabler_button("launch", "Go!", width = "25%")
)
server <- function(input, output, session) {
observe(print(input$toast))
observeEvent(input$launch, {
removeNotification("notif")
show_tabler_toast(
"toast",
options = list(
animation = FALSE,
delay = 3000
)
)
})
observeEvent(input$toast, {
showNotification(
id = "notif",
"Toast was closed",
type = "warning",
duration = 1,
)
})
}
shinyApp(ui, server)
The result of shown in Figure 19.3.
19.2.4 Exercise
Based on the Tabler documentation, add the tabler_tooltip()
function. Hint: you may also check the corresponding Bootstrap 4 help.
19.4 Tab events
Do you remember about the navbar element and the tabsetpanel system of Chapter 17? Navs allow users to organize any app into several tabs, acting like a multi-page application. This is a powerful tool for Shiny since it is currently not straightforward to create multi-pages Shiny apps like anyone would do in a standard website. Navs rely on the Bootstrap 4 API but we only use a few JS functions.
19.4.1 Insert/remove tabs in tabsetpanel
How about dynamically inserting/removing tabs from a tabler_navbar()
? We chose this example since it involves extra technical details about Shiny, especially process extra dependencies at render time.
How do we proceed? If you recall about the tabler_navbar_menu_item()
and tabler_tab_item()
coupling, inserting a tab implies inserting the trigger in the navigation menu as well as the content in the dashboard body. Therefore, we need to know the structure of what we insert. Below is a reminder:
-
tabler_navbar_menu_item()
are<li>
elements. -
tabler_tab_item()
is a<div>
element with specific classes.
<li class="nav-item">
<a class="nav-link" href="#ww" data-toggle="pill"
data-value="ww" role="tab">
<span class="nav-link-icon d-md-none d-lg-inline-block">
</span>
<span class="nav-link-title">ww</span>
</a>
</li>
<div role="tabpanel" class="tab-pane fade container-fluid"
id="ww">
</div>
We design the insert_tabler_tab()
function similar to the Shiny insertTab()
.
insert_tabler_tab <- function(
inputId,
tab,
target,
position = c("before", "after"),
select = FALSE,
session = getDefaultReactiveDomain()
) {
# SEE BELOW
}
To handle shiny modules, we wrap the inputId in the session namespace session$ns.
inputId <- session$ns(inputId)
We recover the provided new tab position, leveraging match.arg()
:
position <- match.arg(position)
We create the menu item element based on the new tab and the above HTML structure:
navbar_menu_item <- tags$li(
class = "nav-item",
a(
class = "nav-link",
href = "#",
`data-target` = paste0("#", session$ns(tab$attribs$id)),
`data-toggle` = "pill",
`data-value` = tab$attribs$id,
role = "tab",
tab$attribs$id
)
)
Since JS does not understand Shiny tags, we have to convert both tab and the nav link to character:
tab <- as.character(tab)
navbar_menu_item <- as.character(navbar_menu_item)
We finally, build our message as a list, whose NULL
elements are handled by dropNulls()
and send it to JS with session$sendCustomMessage
.
message <- dropNulls(
list(
inputId = inputId,
content = tab,
link = navbar_menu_item,
target = target,
position = position,
select = select
)
)
session$sendCustomMessage(type = "insert-tab-1", message)
On the JS side, we capture the R message (list) in two elements:
-
$divTag
contains the tab content. -
$liTag
contains the tab link, i.e. the navigation part.
Depending on the position parameter, we use the insertAfter()
and insertBefore()
jQuery methods. Finally, if the newly inserted tab has to be selected, we activate the corresponding tab element with $(tablink).tab('show')
.
$(function() {
.addCustomMessageHandler(
Shiny'insert-tab-1', function(message) {
// define div and li targets
let $divTag = $(message.content);
let $liTag = $(message.link);
let targetId = '#' + message.target;
if (message.position === 'after') {
.insertAfter($(targetId));
$divTag.insertAfter(
$liTag$('[data-target="' + targetId + '"]')
.parent()
;
)else if (message.position === 'before') {
} .insertBefore($(targetId));
$divTag.insertBefore(
$liTag$('[data-target="' + targetId + '"]')
.parent()
;
)
}
if (message.select) {
// trigger a click on corresponding the new tab button.
let newTabId = $divTag.attr('id');
$('#' +
.inputId +
message' a[data-target="#' +
+'"]').tab('show');
newTabId
};
}); })
### RUN ###
# OSUICode::run_example(
# "tabler/insert-tab-1",
# package = "OSUICode"
# )
If the tab is well inserted, we notice that the slider and the plot are not properly shown, as illustrated in Figure 19.5.
How could we explain that? It is a dependency issue: the slider input relies on a specific JS library, namely ionRangesSlider, as depicted in Figure 19.6. In our previous example, if you open the HTML inspector, the dependency is not included in the page.
Even stranger, when we use renderUI()
to conditionally render the slider, the dependency is only included when the go button is pressed. How does Shiny include them?
### RUN ###
# OSUICode::run_example(
# "tabler/renderUI",
# package = "OSUICode"
# )
### APP CODE ###
library(shiny)
ui <- fluidPage(
actionButton("go", "Go!", class = "btn-success"),
uiOutput("slider"),
plotOutput("distPlot")
)
# Server logic
server <- function(input, output) {
output$slider <- renderUI({
req(input$go > 0)
sliderInput(
"obs",
"Number of observations:",
min = 0,
max = 1000,
value = 500
)
})
output$distPlot <- renderPlot({
req(input$obs)
hist(rnorm(input$obs))
})
}
# Complete app with UI and server components
shinyApp(ui, server)
Let’s look at renderUI()
:
renderUI <- function (
expr,
env = parent.frame(),
quoted = FALSE,
outputArgs = list()
) {
installExprFunction(expr, "func", env, quoted)
createRenderFunction(func, function(result, shinysession,
name, ...) {
if (is.null(result) || length(result) == 0)
return(NULL)
processDeps(result, shinysession)
}, uiOutput, outputArgs)
}
The last line returned is processDeps(result, shinysession)
. As stated in section 15.3, this function is responsible to handle dependencies during run time. shiny:::processDeps
(R side) works with Shiny.renderContent
(JS side), as already mentioned in part 15.3. The latter takes a tag element as well as an object containing its HTML code and dependencies, for instance:
.renderContent($tag[0], {
exportshtml: $tag.html(),
deps: message.tag.deps
; })
Note that, as it is bad practice to rely on other packages’s internal functions, we’ll rely on htmltools::renderTags
instead of shiny:::processDeps
, being very similar.
In the following, we modify the insert_tabler_tab()
to include the dependencies processing step. This actually requires changing only three lines of code (the last one consists of pointing to the new JS handler):
<- function(
insert_tabler_tab_2
inputId,
tab,
target, position = c("before", "after"),
select = FALSE,
session = getDefaultReactiveDomain()
) {
# ... Unchanged
<- htmltools::renderTags(tab),
content <- htmltools::renderTags(navbar_menu_item),
link
$sendCustomMessage("insert-tab-2", message)
session }
We then apply the Shiny.renderContent
method to the tab content and navigation item. Below, we only show what changed compared to the first handler defined earlier:
$(function() {
.addCustomMessageHandler('insert-tab-2',
Shinyfunction(message) {
// message.content and message.link are objects
// with 2 fields: html (tag) and deps (dependencies)
// We only need HTML for now.
let $divTag = $(message.content.html);
let $liTag = $(message.link.html);
// Unchanged
// Render input/output in newly added tab.
// It takes the possible deps and add them to the tag.
.renderContent(
Shiny0],
$liTag[html: $liTag.html(), deps: message.link.dependencies}
{;
).renderContent(
Shiny0],
$divTag[html: $divTag.html(), deps: message.content.dependencies}
{;
)
// Trigger show: unchanged
;
}); })
We check if our approach works as expected.
### RUN ###
# OSUICode::run_example(
# "tabler/insert-tab-2",
# package = "OSUICode"
# )
Et voila! As shown in Figure 19.7, everything is properly displayed.
19.4.2 Exercise
Taking inspiration from the insert_tabler_tab()
function, write the remove_tabler_tab()
function.