Skip to content

fig-cap on htmlwidgets produces an unassociated paragraph instead of a figure #14692

Description

@cwickham

I have:

  • searched the issue tracker for similar issues
  • installed the latest version of Quarto CLI (1.10.12 pre-release)
  • formatted my issue following the Bug Reports guide

Bug description

For cells whose output is an htmlwidget (plotly, leaflet, DT, …), figure handling diverges from static plots, and the result is inaccessible output:

  1. fig-cap produces broken semantics. A static plot gets <figure class="figure"><img …><figcaption>…</figcaption></figure>. A widget gets the caption pasted after it as a bare <p> — visually it reads as a caption (in a dashboard the fill layout even pushes it to the card's bottom edge), but there is no <figure>/<figcaption> and no programmatic association with the widget.

  2. The widget itself has no accessible name. htmlwidget containers land in the accessibility tree as role: generic with an empty name — for a plotly chart, ~175 elements without a single role, aria-*, or alt anywhere. Leaflet is the sharpest case: its container ships with tabindex="0", so Quarto's rendered output contains a keyboard-focusable element with no accessible name — a WCAG 2.2 SC 4.1.2 (Name, Role, Value) failure. There is no author-side escape hatch: widget R packages expose no labelling option (no aria anywhere in the plotly/leaflet/htmlwidgets sources), fig-alt doesn't apply to widgets, and axe-core doesn't flag unnamed generic divs, so axe: true gives no signal.

(An upstream fix in plotly.js — plotly/plotly.js#6920, open since 2024 — wouldn't cover this: in dashboards the chart's title is the card header, outside the widget, and other widget libraries would remain unnamed regardless.)

Steps to reproduce

Render and inspect either widget in DevTools → Accessibility pane:

---
title: "Widget captions and accessible names"
format: dashboard
---

## Row

### Column

#### Widget chart

```{r}
#| fig-cap: "WIDGET-CAPTION daily ED visits"
plotly::plot_ly(x = 1:5, y = c(2, 4, 8, 5, 3), type = "scatter", mode = "lines")
```

### Column

#### Static chart

```{r}
#| fig-cap: "STATIC-CAPTION daily ED visits"
plot(1:5, c(2, 4, 8, 5, 3), type = "l")
```

Actual behavior

  • Widget caption: …</script><p>WIDGET-CAPTION daily ED visits</p> — a sibling paragraph, no association. (Same in format: html.)
  • Static caption: proper <figure>/<figcaption>, even inside the dashboard card.
  • Widget container in the accessibility tree: role: generic, name empty. With a leaflet widget, the container is additionally focusable (tabindex="0") with no name.

Expected behavior

Widget output takes the same figure path as static plots:

  • fig-cap → wrap the widget in <figure>/<figcaption>, giving the caption→widget association for free via HTML semantics (a native <figure> also carries the figure role implicitly).
  • fig-altaria-label on the placeholder <div class="… html-widget …"> itself, so a focusable container (leaflet) is announced by name on focus. Reusing fig-alt keeps one mental model and means a document stays accessible when a static plot is swapped for its interactive equivalent. (Docs note: the name doesn't make chart data accessible — authors should still provide a table or text summary.)

Prototyped and verified in the Chrome accessibility tree: attributes set statically on the placeholder survive widget initialization (plotly, leaflet, and DT all initialize in place without touching them); figure keeps widget internals accessible (plotly modebar, leaflet zoom buttons, and DT's table/searchbox/pagination semantics all intact).

Implementation-wise both halves are local to the knitr shim that already intercepts every htmlwidget — add_html_captionwrap_asis_output(options, x), where options[["fig.alt"]] and the placeholder HTML string are both in scope (verified by probing a live render):

# override wrapping behavior for knitr_asis output (including htmlwidgets)
# to provide for enclosing output div and support for figure captions
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)
}
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")

Known limitation: this path is knitr-only; Jupyter has no common placeholder marker (Python plotly emits plotly-graph-div), so cross-engine support would need per-engine handling.

Your environment

  • IDE: none (rendered with Quarto CLI from the terminal)
  • macOS 26.5.1 (build 25F80)
  • R 4.6.0, plotly 4.12.0, leaflet 2.2.3, DT (current CRAN)

Investigation was AI-assisted, grounded in a local clone of quarto-cli (per CONTRIBUTING.md "Using AI tools to investigate"); behavior claims verified against rendered output and the Chrome accessibility tree.

Quarto check output

Quarto 1.10.12
[✓] Checking environment information...
      Quarto cache location: /Users/charlottewickham/Library/Caches/quarto
[✓] Checking versions of quarto binary dependencies...
      Pandoc version 3.8.3: OK
      Dart Sass version 1.87.0: OK
      Deno version 2.7.14: OK
      Typst version 0.14.2: OK
[✓] Checking versions of quarto dependencies......OK
[✓] Checking Quarto installation......OK
      Version: 1.10.12
      Path: /Applications/quarto/bin
[✓] Checking tools....................OK
      TinyTeX: v2026.04
      Chrome Headless Shell: 150.0.7871.115
      VeraPDF: 1.28.2
[✓] Checking LaTeX....................OK
      Using: TinyTex
      Path: /Users/charlottewickham/Library/TinyTeX/bin/universal-darwin
      Version: 2026
[✓] Checking Chrome Headless....................OK
      Using: Chrome Headless Shell installed by Quarto
      Path: /Users/charlottewickham/Library/Application Support/quarto/chrome-headless-shell/chrome-headless-shell-mac-arm64/chrome-headless-shell
      Version: 150.0.7871.115
[✓] Checking basic markdown render....OK
[✓] Checking R installation...........OK
      Version: 4.6.0
      Path: /Library/Frameworks/R.framework/Versions/4.6/Resources
      LibPaths:
        - /Users/charlottewickham/Library/R/arm64/4.6/library
        - /Library/Frameworks/R.framework/Versions/4.6/Resources/library
      knitr: 1.51
      rmarkdown: 2.31
[✓] Checking Knitr engine render......OK
[✓] Checking Python 3 installation....OK
      Version: 3.12.2
      Path: /Users/charlottewickham/.pyenv/versions/3.12.2/bin/python3
      Jupyter: 5.9.1
      Kernels: python3
[✓] Checking Jupyter engine render....OK
[✓] Checking Julia installation...

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions