Thanks to @jfieberg for the report in #14515.
htmlwidgets that emit their own aria-labelledby attribute pointing to a sibling label element produce broken ARIA references in Quarto HTML output. The label target element is expected to be supplied by the renderer (knitr or Quarto), but Quarto's override of knitr::add_html_caption() silently discards the id and never emits the target. WAVE and axe report the broken reference at scan time.
The concrete trigger is rgl's rglwidget(), but any htmlwidget that uses the same pattern (own role="img" + aria-labelledby, with no embedded target) will hit this.
Reproduction
---
title: "RGL Accessibility"
format: html
---
::: {#fig-rgl}
```{r}
#| echo: false
library(rgl)
setupKnitr(autoprint = FALSE)
plot3d(iris$Sepal.Length, iris$Sepal.Width, iris$Petal.Length,
col = as.numeric(iris$Species), type = "s", size = 1)
rglwidget()
```
An interactive 3D plot.
:::
Open in browser, run WAVE → two "Broken ARIA reference" errors on the rgl widget's outer <div> and on the <canvas> it injects at runtime. Both aria-labelledby attributes reference htmlwidget-XXXX-aria, but no element with that id exists.
Mechanism
rgl emits aria-labelledby on the outer widget div statically (source):
widget_html.rglWebGL <- function(id, style, class, ...){
result <- tags$div(id = id, style = style, class = class, role = "img",
"aria-labelledby" = ariaLabelId(id))
# In shiny, we need to write the alt text label.
if (inShiny())
result <- tags$div(tags$p("3D plot 1", id = ariaLabelId(id),
hidden = NA),
result)
result
…and on the <canvas> dynamically when the widget initializes (source):
labelid = this.el.getAttribute("aria-labelledby");
newcanvas.width = this.el.width;
newcanvas.height = this.el.height;
newcanvas.setAttribute("aria-labelledby",
labelid);
rgl deliberately skips emitting its own <p id="X-aria" hidden>alt</p> target whenever knitr ≥ 1.42.12 is in use, on the assumption that knitr (or its host) will emit the label (source):
# We always emit aria-labelledby. We need to
# choose here whether to write the label, or rely
# on other code to write it. We let other code write it
# in new knitr and Shiny, and otherwise do it ourselves.
if (!in_knitr_with_altText_support() && !inShiny())
result <- htmlwidgets::prependContent(result,
tags$p(altText, id = ariaLabelId(elementId),
hidden = NA))
knitr ≥ 1.43 added that capability — in non-Quarto mode, sew.knit_asis() extracts the aria-labelledby value from the widget HTML via regex and passes it as id to add_html_caption(), which then emits the hidden <p> target (source):
if (inherits(x, 'knit_asis_htmlwidget')) {
options$fig.cur = plot_counter()
options = reduce_plot_opts(options)
# TODO: remove this when quarto > 1.3.353 is widely used
if (is_quarto()) return(add_html_caption(options, wrap_asis(x, options)))
# look for attribute 'aria-labelledby="label"' in the first HTML tag and
# use the label to provide alt text if found
return(add_html_caption(
options, wrap_asis(x, options),
xfun::grep_sub('^[^<]*<[^>]+aria-labelledby[ ]*=[ ]*"([^"]+)".*$', '\\1', x)
))
}
And add_html_caption() itself (source):
add_html_caption = function(options, code, id = NULL) {
cap = .img.cap(options)
if (cap == '' && !length(id)) return(code)
if (length(id)) {
alt = .img.cap(options, alt = TRUE, escape = TRUE)
if (cap == alt && cap != '') {
# both are the same, so insert cap with id
alttext = sprintf('<p class="caption" id="%s">%s</p>\n', id, cap)
# prevent a second insertion
cap = ''
} else {
alttext = sprintf('<p id="%s" hidden>%s</p>\n', id, alt)
}
} else alttext = ''
captext = if (cap == '') '' else sprintf('<p class="caption">%s</p>\n', cap)
In Quarto mode, the early-return on line 525 skips the regex extraction and calls add_html_caption(options, wrap_asis(x, options)) with no id. Even if that branch passed the id, Quarto replaces add_html_caption in knitr's namespace with a version that swallows it via ...:
|
add_html_caption <- function(options, x, ...) { |
|
if (inherits(x, 'knit_asis_htmlwidget')) { |
|
wrap_asis_output(options, x) |
|
} else { |
|
x |
|
} |
|
} |
|
assignInNamespace("add_html_caption", add_html_caption, ns = "knitr") |
wrap_asis_output wraps the widget in a cell-output-display div and appends the figure caption, but never injects an aria target element:
|
wrap_asis_output <- function(options, x) { |
|
# if the options are empty then this is inline output, return unmodified |
|
if (length(options) == 0) { |
|
return(x) |
|
} |
|
# x needs to be collapsed first as it could be a character vector (#5506) |
|
x <- paste(x, collapse = "") |
|
|
|
# generate output div |
|
caption <- figure_cap(options)[[1]] |
|
if (nzchar(caption)) { |
|
x <- paste0(x, "\n\n", caption) |
|
} |
|
classes <- paste0("cell-output-display") |
|
attrs <- NULL |
|
if (isTRUE(options[["output.hidden"]])) { |
|
classes <- paste0(classes, " .hidden") |
|
} |
|
|
|
if (identical(options[["html-table-processing"]], "none")) { |
|
attrs <- paste(attrs, "html-table-processing=none") |
|
} |
|
|
|
# if this is an html table then wrap it further in ```{=html} |
|
# (necessary b/c we no longer do this by overriding kable_html, |
|
# which is in turn necessary to allow kableExtra to parse |
|
# the return value of kable_html as valid xml) |
|
if ( |
|
grepl("^<\\w+[ >]", x) && |
|
grepl("<\\/\\w+>\\s*$", x) && |
|
!grepl('^<div class="kable-table">', x) |
|
) { |
|
x <- paste0("`````{=html}\n", x, "\n`````") |
|
} |
|
|
|
# If asis output, don't include the output div |
|
if (identical(options[["results"]], "asis")) { |
|
return(x) |
|
} |
|
|
|
output_div(x, output_label_placeholder(options), classes, attrs) |
|
} |
Result: rgl's two aria-labelledby references resolve to nothing → two broken-reference errors per widget.
The figure-level ARIA wiring Quarto does generate (aria-describedby on the float content div, matching figcaption id in floatreftarget.lua) works correctly for plain images and for htmlwidgets that don't emit their own aria attributes (e.g., plotly). That part is not the bug.
How this regressed
The ...-swallow in add_html_caption was added in #5704 as a deliberate minimal hotfix for the "unused argument" crash introduced when knitr 1.43 added the id parameter (#5702, #5762). The PR body explicitly flagged this as a hotfix rather than feature parity:
"I only did this specific function to make a possible hot fix minimal, but we should probably do that in all our assignInNamespace() call."
The crash was fixed; the aria-labelledby support knitr was wiring up has been silently dropped on the Quarto side since then.
Possible fix
We could extract the aria-labelledby id from the widget HTML in Quarto's add_html_caption (the same regex knitr uses) and have wrap_asis_output emit a hidden <p id="X-aria" hidden>alt</p> next to the widget. That keeps the fix entirely on the Quarto side and doesn't require any knitr coordination.
Related
Thanks to @jfieberg for the report in #14515.
htmlwidgets that emit their own
aria-labelledbyattribute pointing to a sibling label element produce broken ARIA references in Quarto HTML output. The label target element is expected to be supplied by the renderer (knitr or Quarto), but Quarto's override ofknitr::add_html_caption()silently discards the id and never emits the target. WAVE and axe report the broken reference at scan time.The concrete trigger is rgl's
rglwidget(), but any htmlwidget that uses the same pattern (ownrole="img"+aria-labelledby, with no embedded target) will hit this.Reproduction
Open in browser, run WAVE → two "Broken ARIA reference" errors on the rgl widget's outer
<div>and on the<canvas>it injects at runtime. Botharia-labelledbyattributes referencehtmlwidget-XXXX-aria, but no element with that id exists.Mechanism
rgl emits
aria-labelledbyon the outer widget div statically (source):…and on the
<canvas>dynamically when the widget initializes (source):rgl deliberately skips emitting its own
<p id="X-aria" hidden>alt</p>target whenever knitr ≥ 1.42.12 is in use, on the assumption that knitr (or its host) will emit the label (source):knitr ≥ 1.43 added that capability — in non-Quarto mode,
sew.knit_asis()extracts thearia-labelledbyvalue from the widget HTML via regex and passes it asidtoadd_html_caption(), which then emits the hidden<p>target (source):And
add_html_caption()itself (source):In Quarto mode, the early-return on line 525 skips the regex extraction and calls
add_html_caption(options, wrap_asis(x, options))with no id. Even if that branch passed the id, Quarto replacesadd_html_captionin knitr's namespace with a version that swallows it via...:quarto-cli/src/resources/rmd/patch.R
Lines 142 to 149 in 676e0b0
wrap_asis_outputwraps the widget in acell-output-displaydiv and appends the figure caption, but never injects an aria target element:quarto-cli/src/resources/rmd/patch.R
Lines 99 to 140 in 676e0b0
Result: rgl's two
aria-labelledbyreferences resolve to nothing → two broken-reference errors per widget.The figure-level ARIA wiring Quarto does generate (
aria-describedbyon the float content div, matchingfigcaptionid infloatreftarget.lua) works correctly for plain images and for htmlwidgets that don't emit their own aria attributes (e.g., plotly). That part is not the bug.How this regressed
The
...-swallow inadd_html_captionwas added in #5704 as a deliberate minimal hotfix for the "unused argument" crash introduced when knitr 1.43 added theidparameter (#5702, #5762). The PR body explicitly flagged this as a hotfix rather than feature parity:The crash was fixed; the aria-labelledby support knitr was wiring up has been silently dropped on the Quarto side since then.
Possible fix
We could extract the
aria-labelledbyid from the widget HTML in Quarto'sadd_html_caption(the same regex knitr uses) and havewrap_asis_outputemit a hidden<p id="X-aria" hidden>alt</p>next to the widget. That keeps the fix entirely on the Quarto side and doesn't require any knitr coordination.Related
add_html_caption(): #5702 / Error inadd_html_caption(): ! unused argument (xfun::grep_sub("^[^<]*<[^>]+aria-labelledby[ ]*=[ ]*\"([^\"]+)\".*$", "\\1", x)) #5762 — originaladd_html_captioncrash after knitr 1.43add_html_caption()as we overwrite knitr's internals #5704 — the minimal...hotfix