2 Manipulate HTML tags from R with {htmltools}
htmltools (Cheng, Sievert, et al. 2021) is a R package designed to:
- Generate HTML tags from R.
- Handle web dependencies (see chapter 4).
Historically, htmltools was extracted out of shiny (Chang et al. 2021) to be able to extend it, that is developing custom HTML tags, importing extra dependencies from the web. That’s why, both packages have many common functions!
The ultimate goal of htmltools is to manipulate, combine and rearrange tags in order to create flexible and rich HTML structures from R. Would you believe that the below example heavily relies on htmltools (Figure 2.1)?
If you want to try out this example, below is the showcase code:
remotes::install_github("RinteRface/shinyRPG")
library(shinyRPG)
shinyRPGDemo()
2.2 Notations
If you type htmltools::tags$
in the R console, you should be suggested the most common available HTML tags, thereby making it fairly easy to switch between HTML and R, as shown Figure 2.2.
For convenience, the most commonly used tags like p
, h1
, h2
, h3
, h4
, h5
, h6
, a
, br
, div
, span
, pre
, code
, img
, strong
, em
, hr
, …
are accessible by a simple function call like:
# good
h1("This is a title")
# correct but not necessary
tags$h1("This is a title")
Therefore, whether to use tags$<TAG_NAME
or <TAG_NAME>
depends if the tag is exported by default. Since nav
is not exported, we write:
# correct
tags$nav("This is the navigation")
# fail
try(nav("This is the navigation"))
When building custom templates, you will be writing a lot of tags. It might seem
too much work to always write tags$<TAG_NAME>
. There exists a function called withTags()
, which allows to get rid of all tags$
. Hence, the whole code is much easier to write and read:
# Better
withTags(
nav(div(), ul(li(), li()))
)
# instead of
tags$nav(div(), tags$ul(tags$li(), tags$li()))
If you had to gather multiple tags together, choose tagList()
over list()
, although the HTML output is the same:
#> List of 2
#> $ :List of 3
#> ..$ name : chr "p"
#> ..$ attribs : Named list()
#> ..$ children:List of 1
#> .. ..$ : chr "Some text"
#> ..- attr(*, "class")= chr "shiny.tag"
#> $ :List of 3
#> ..$ name : chr "div"
#> ..$ attribs : Named list()
#> ..$ children:List of 1
#> .. ..$ : chr "Content"
#> ..- attr(*, "class")= chr "shiny.tag"
#> - attr(*, "class")= chr [1:2] "shiny.tag.list" "list"
tag_list_1
#> <p>Some text</p>
#> <div>Content</div>
#> List of 2
#> $ :List of 3
#> ..$ name : chr "p"
#> ..$ attribs : Named list()
#> ..$ children:List of 1
#> .. ..$ : chr "Some text"
#> ..- attr(*, "class")= chr "shiny.tag"
#> $ :List of 3
#> ..$ name : chr "div"
#> ..$ attribs : Named list()
#> ..$ children:List of 1
#> .. ..$ : chr "Content"
#> ..- attr(*, "class")= chr "shiny.tag"
tag_list_2
#> [[1]]
#> <p>Some text</p>
#>
#> [[2]]
#> <div>Content</div>
The first has the shiny.tag.list
class in addition to list
. You may see it as a detail but this has noticeable consequences. For instance, tag_list_1
prints as HTML content whereas tag_list_2
prints as a list. If we try to apply as.character()
on both elements, we obtain very different outputs:
# tag_list_1
as.character(tag_list_1)
#> [1] "<p>Some text</p>\n<div>Content</div>"
# tag_list_2
as.character(tag_list_2)
#> [1] "list(name = \"p\", attribs = list(), children = list(\"Some text\"))"
#> [2] "list(name = \"div\", attribs = list(), children = list(\"Content\"))"
Besides, packages like golem (Fay et al. 2021) allows to test if an R object is a tag list. In this case, using a simple list would cause the test fail.
2.6 Modern {htmltools}
This section requires basic CSS knowledge, particularly CSS selectors. Please read Chapter 6 before going further.
As of htmltools 0.5.2, the new tagQuery()
function makes manipulating shiny tags a real pleasure, in addition to be more efficient. If you know and like jQuery (Chapter 10.5), the API is really similar. If you don’t know jQuery yet, no problem, we’ll see it later in the book!
As a preliminary example, we want to modify the third span
element from the above example:
spans <- div(div(p(), lapply(1:5, function(i) span(i))))
spans$children[[1]]$children[[2]][[3]]$attribs$class <- "test"
spans
#> <div>
#> <div>
#> <p></p>
#> <span>1</span>
#> <span>2</span>
#> <span class="test">3</span>
#> <span>4</span>
#> <span>5</span>
#> </div>
#> </div>
Below is the new htmltools approach which leverages tagQuery()
:
spans <- div(div(p(), lapply(1:5, function(i) span(i))))
spans <- tagQuery(spans)$
find("span")$
filter(function(x, i) i == 3)$
addAttrs("class" = "amazing-tag")$
allTags()
spans
#> <div>
#> <div>
#> <p></p>
#> <span>1</span>
#> <span>2</span>
#> <span class="amazing-tag">3</span>
#> <span>4</span>
#> <span>5</span>
#> </div>
#> </div>
As you may notice, the first approach may lead to poorly written code as soon as the
tag structure gets more complex. You may easily end up with things like tag$children[[1]]$children[[2]]$children[[1]]$attribs$class
which is nearly impossible to maintain.
The second approach is much more human readable, even though not necessarily shorter in this example.
The biggest advantage is that is does not always depends on the overall tag structure. As an exercise, you may wrap the span
elements inside another div
parent:
spans <- div(div(p(), div(lapply(1:5, function(i) span(i)))))
spans <- tagQuery(spans)$
find("span")$
filter(function(x, i) i == 3)$
addAttrs("class" = "amazing-tag")$
allTags()
spans
#> <div>
#> <div>
#> <p></p>
#> <div>
#> <span>1</span>
#> <span>2</span>
#> <span class="amazing-tag">3</span>
#> <span>4</span>
#> <span>5</span>
#> </div>
#> </div>
#> </div>
The above code still works, while the previous one would require to be updated.
Another reason to prefer the new tagQuery()
API is the substantial performance gains. Interestingly, under the hood, most if not all older htmltools functions like tagAppendChildren()
or tagAppendAttributes()
call the tagQuery()
API when .cssSelector
is provided. In practice,
while one can achieve multiple modifications at once with a single tagQuery()
call, it requires a combination of multiple tagAppendChildren()
/tagAppendAttributes()
to reach the same result, thereby leading to less performance.
Are you ready to become a tag witcher?
2.6.1 Basics
tagQuery()
accepts a tag or list of tags as input and returns a data structure containing:
-
$allTags()
: all tags. -
$selectedTags()
: selected tags, default to$allTags()
.
As an example:
#> [1] "shiny.tag.query"
tag_query
#> `$allTags()`:
#> <div>
#> <p></p>
#> </div>
#>
#> `$selectedTags()`: `$allTags()`
As shown above, the returned result is not a shiny tag. Instead, it is a R6 class having methods to handle those tags.
2.6.4 Chain tag queries
One of the strength of the tagQuery()
API is the ability to chain methods, where the classic htmltools syntax might be repetitive and heavy. $resetSelected()
allows to reset the tag selection to the root tag after a given operation, thereby making it possible to chain multiple queries with different purposes. The overall flow is more human readable than a step by step approach, similarly to the tidyverse
or ggplot
grammar. Let’s combine all previous examples:
# add fade class to all panels
tagQuery(tabs)$
find(".tab-pane")$
addClass("fade")$
removeClass("active")$
filter(function(x, i) tagGetAttribute(x, "data-value") == 3)$
addClass("active")$
resetSelected()$
# new operation: add icon before each nav link title
find("a")$
prepend(icon("flag"))$
# Here the next operation use the same target
# We don't need to reset the scope
removeClass("active")$
# Select third nav link
filter(function(x, i) tagGetAttribute(x, "data-value") == 3)$
# Make it active
addClass("active")$
allTags()
#> <div class="tabbable">
#> <ul class="nav nav-tabs" data-tabsetid="8032">
#> <li class="nav-item">
#> <a href="#" data-toggle="tab" data-value="1" class="nav-link" data-target="#tab-8032-1">
#> <i class="fa fa-flag" role="presentation" aria-label="flag icon"></i>
#> 1
#> </a>
#> </li>
#> <li class="nav-item">
#> <a href="#" data-toggle="tab" data-value="2" class="nav-link" data-target="#tab-8032-2">
#> <i class="fa fa-flag" role="presentation" aria-label="flag icon"></i>
#> 2
#> </a>
#> </li>
#> <li class="nav-item">
#> <a href="#" data-toggle="tab" data-value="3" class="nav-link active" data-target="#tab-8032-3">
#> <i class="fa fa-flag" role="presentation" aria-label="flag icon"></i>
#> 3
#> </a>
#> </li>
#> </ul>
#> <div class="tab-content" data-tabsetid="8032">
#> <div class="tab-pane fade" data-value="1" id="tab-8032-1">Tab 1</div>
#> <div class="tab-pane fade" data-value="2" id="tab-8032-2">Tab 2</div>
#> <div class="tab-pane fade active" data-value="3" id="tab-8032-3">Tab 3</div>
#> </div>
#> </div>
2.6.5 Specific cases
There are situations where the previous methods won’t work. What if you want to modify all
tabs content from Tab i
to This is tab i
. A common mistake would be to proceed as follows:
tagQuery(tabs)$
find(".tab-pane")$
empty()$
append(lapply(1:3, function(i) paste("This is tab", i)))
#> [[1]]
#> <div class="tab-pane active" data-value="1" id="tab-8032-1">
#> This is tab 1
#> This is tab 2
#> This is tab 3
#> </div>
#>
#> [[2]]
#> <div class="tab-pane" data-value="2" id="tab-8032-2">
#> This is tab 1
#> This is tab 2
#> This is tab 3
#> </div>
#>
#> [[3]]
#> <div class="tab-pane" data-value="3" id="tab-8032-3">
#> This is tab 1
#> This is tab 2
#> This is tab 3
#> </div>
You may think it will add each text to the corresponding panel item but methods like $append()
and $prepend()
are only able to add the same element(s) to one or multiple target(s). It will actually adds three new children to each selected panel. For this case where the content is index specific, we have to utilize $each()
. It takes an anonymous function as input, with two parameters, x is the tag and i is the current index. Inside that function, you may edit the tag depending on the index and return the modified structure:
tagQuery(tabs)$
find(".tab-pane")$
empty()$
each(function(x, i) {
# replace text
x <- tagAppendChildren(x, paste("This is tab", i))
# return edited tag
x
})
#> [[1]]
#> <div class="tab-pane active" data-value="1" id="tab-8032-1">This is tab 1</div>
#>
#> [[2]]
#> <div class="tab-pane" data-value="2" id="tab-8032-2">This is tab 2</div>
#>
#> [[3]]
#> <div class="tab-pane" data-value="3" id="tab-8032-3">This is tab 3</div>
2.6.6 Practice
Let’s rewrite the shinyRPG select (see 2.5.2) input using the new tagQuery()
API. As a reminder, the function is given here. The new approach is described below and leverages almost all the tagQuery()
API tools. We first remove the outer div class and the label class with $removeAttrs()
. These two steps don’t need any reset since the label is a child of the outer div. The next step, that is targeting the select element requires to use $siblings()
to catch the div
parent followed by a $children()
, which will capture the select (interestingly, we could also have reset the selection with $resetSelected()
and apply $find()
). We add it a custom class with $addClass()
. We finally go back to the parent outer div
with $resetSelected()
and apply $each()
to replace the inner div
by its children.
tagQuery(selectTag)$
removeAttrs("class")$ # remove outer div class
find(".control-label")$
removeAttrs("class")$ # remove class from child label
siblings()$ # go down to the div
children()$ # go down to the select tag
addClass(selectClass)$ # add class to child select
resetSelected()$# go back to div parent
each(function(x, i) {
x$children[[2]] <- x$children[[2]]$children
})$ # replace div parent
allTags()
The complete code may be found in A.1.1.
2.6.7 Alter tag rendering with render hooks
2.6.7.1 Simple hooks
In this section, we’ll see what is probably one of the most advanced htmltools feature, recently introduced in 0.5.2. How would you conditionally render a tag, for instance depending on different conditions like external options, a specific theme version, …?
Assume you want to design development specific tags, that only appear in Shiny dev
mode:
#> [1] TRUE
Then we create our custom tag with the .renderHook
parameter available for htmltools tag element, providing a special function, namely a render hook, that will be called upon tag rendering:
cssStyle <- "color:red; border-style:dashed; border-color:blue;"
customTag <- span("", .renderHook = function(x) {
if (getOption("shiny.devmode")) {
tagAppendAttributes(x, style = cssStyle)
}
})
customTag
#> <span style="color:red; border-style:dashed; border-color:blue;"></span>
Note that if the tag already has any existing hook, tagAddRenderHook()
adds another hook to the current list. An option controls whether to erase existing hooks (replace
).
customTag <- tagAddRenderHook(customTag, function(x) {
if (getOption("shiny.devmode")) {
tagAppendChildren(x, "UNDER REWORK")
}
})
The hooks list is accessible with:
customTag$.renderHooks
#> [[1]]
#> function(x) {
#> if (getOption("shiny.devmode")) {
#> tagAppendAttributes(x, style = cssStyle)
#> }
#> }
#>
#> [[2]]
#> function(x) {
#> if (getOption("shiny.devmode")) {
#> tagAppendChildren(x, "UNDER REWORK")
#> }
#> }
# Remove first hook
# customTag$.renderHooks[[1]] <- NULL
which is convenient to remove or edit hooks. Figure 2.9 summarizes the main mechanisms.
Let’s try it in a Shiny app with the dev
mode enabled:
ui <- fluidPage(
sidebarLayout(
sidebarPanel(customTag),
mainPanel(customTag)
)
)
server <- function(input, output, session) {}
shinyApp(ui, server)
We disable the dev
mode:
The result is shown Figure 2.10.
In Chapter 9.3.3 we’ll discuss another use case, which is about theme-dependent rendering, that is for instance, render a tag differently whether Shiny is in Bootstrap 3 or Bootstrap 4 mode.
2.6.7.2 Nested hooks
In practice, you will rarely have only one render hook to handle. Most Shiny elements are composed
of main wrappers and nested tags. For instance, nav elements like tabsetPanel()
and tabPanel()
are, again, the perfect example.
How do we handle render hooks with nested elements? Let’s see below with a simple example.
We first consider the main wrapper, namely my_wrapper_tag()
. The function creates a single div
wrapper inside which are included items, generated with another function. In the render hook, we simulate the impact of
a theme_version
option. We capture all passed items in a list and apply tagQuery()
to edit a given
element targeted with a specific class, using $each()
to loop over all items and add them an id
attribute. We store the query result in a new item variable and replace the old items by the newly generated ones in the main wrapper with $empty()
and $append()
. Note the commented row, we will discuss it later.
my_wrapper_tag <- function(...) {
wrapper <- tags$div(class = "parent", ...)
items <- list(...)
tagAddRenderHook(wrapper, function(x) {
version <- getOption("theme_version")
if (!is.null(version)) {
if (version == "4") {
# resolve sub items
# items <- lapply(items, as.tags)
# INSERT BROWSER TO DEBUG
new_items <- tagQuery(items)$
find(".new-child")$
each(function(x, i) {
tagAppendAttributes(x, id = i)
})$
allTags()
x <- tagQuery(x)$
# replace accordion items processed above
empty()$
append(new_items)$
allTags()
}
}
x
})
}
The next step is to design the nested item function. The tag consists in a
simple div
element with a class. In the render hook, we get the theme version
and depending on the result, we add a child to the item with $append()
.
Note the new-child
class. This is the one targeted one level upper in my_wrapper_tag()
.
my_nested_tag <- function() {
wrapper <- tags$div(class = "nested")
tagAddRenderHook(wrapper, function(x) {
version <- getOption("theme_version")
if (!is.null(version)) {
x <- if (version == "4") {
new_child <- tags$div(class = "new-child")
tagQuery(x)$
append(new_child)$
allTags()
}
}
x
})
}
We test it below:
# Define external option to mimic arbitrary change
options("theme_version" = "4")
my_wrapper_tag(my_nested_tag(), my_nested_tag())
#> <div class="parent">
#> <div class="nested">
#> <div class="new-child"></div>
#> </div>
#> <div class="nested">
#> <div class="new-child"></div>
#> </div>
#> </div>
The code does not seem to work as expected since the child items does not get any new id
attribute,
contrary to what is specified in the my_wrapper_tag()
render hook. What is the problem here?
It is basically a resolve issue. In the top level render hook, the newly added item is not yet available. To check this, we can put a browser()
just before the tagQuery()
flow inside my_wrapper_tag()
:
my_wrapper_tag <- function(...) {
# start
# ...
# TO DEBUG
browser()
new_items <- tagQuery(items)$
find(".new-child")$
each(function(x, i) {
tagAppendAttributes(x, id = i)
})$
allTags()
# ...
# end
}
Then we run tagQuery(items)$find(".new-child")
and capture the output:
#> `$allTags()`:
#> <div class="nested">
#> <div class="new-child"></div>
#> </div>
#> <div class="nested">
#> <div class="new-child"></div>
#> </div>
#>
#> `$selectedTags()`: (Empty selection)
From what we see, tagQuery()
does not manage to find the newly inserted element with new-child
class in the lower level render hook. If we inspect str(items[[1]])
:
#> List of 4
#> $ name : chr "div"
#> $ attribs :List of 1
#> ..$ class: chr "nested"
#> $ children : list()
#> $ .renderHooks:List of 1
#> ..$ :function (x)
#> .. ..- attr(*, "srcref")= 'srcref'
#> int [1:8] 4 31 20 5 31 5 4 20
#> .. .. ..- attr(*, "srcfile")=Classes 'srcfilecopy',
#> 'srcfile' <environment: 0x7fcbf889aba8>
#> - attr(*, "class")= chr "shiny.tag"
We get the confirmation that the child item is not available for htmltools, even if it appears in the code output:
<!-- items[[1]] -->
<div class="nested">
<div class="new-child"></div>
</div>
A workaround is to manually resolve the sub items with as.tags()
, which converts
any arbitrary element to be part of the tag structure. Therefore, if you uncomment items <- lapply(items, as.tags)
in my_wrapper_tag()
, we obtain:
#> List of 4
#> $ name : chr "div"
#> $ attribs :List of 1
#> ..$ class: chr "nested"
#> $ children :List of 1
#> ..$ :List of 3
#> .. ..$ name : chr "div"
#> .. ..$ attribs :List of 1
#> .. .. ..$ class: chr "new-child"
#> .. ..$ children: list()
#> .. ..- attr(*, "class")= chr "shiny.tag"
#> $ .renderHooks: list()
#> - attr(*, "class")= chr "shiny.tag"
confirming the structure is now correctly processed. Running my_wrapper_tag(my_nested_tag(), my_nested_tag())
yields:
<!-- RUN: my_wrapper_tag(my_nested_tag(), my_nested_tag()) -->
<div class="parent">
<div class="nested">
<div class="new-child" id="1"></div>
</div>
<div class="nested">
<div class="new-child" id="2"></div>
</div>
</div>
which is exactly what we want! A real life case study is available later in the book in this section 9.3.3.3.