Skip to content

Broken aria-labelledby for htmlwidgets that emit aria attributes (e.g., rgl) #14518

@cderv

Description

@cderv

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

Metadata

Metadata

Assignees

Labels

accessibilitybugSomething isn't workingengines-knitrAnything regarding knitr engineshtmlIssues with HTML and related web technology (html/css/scss/js)

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions