I have:
Bug description
For cells whose output is an htmlwidget (plotly, leaflet, DT, …), figure handling diverges from static plots, and the result is inaccessible output:
-
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.
-
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-alt → aria-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_caption → wrap_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...
I have:
Bug description
For cells whose output is an htmlwidget (plotly, leaflet, DT, …), figure handling diverges from static plots, and the result is inaccessible output:
fig-capproduces 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.The widget itself has no accessible name. htmlwidget containers land in the accessibility tree as
role: genericwith an empty name — for a plotly chart, ~175 elements without a singlerole,aria-*, oraltanywhere. Leaflet is the sharpest case: its container ships withtabindex="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 (noariaanywhere in the plotly/leaflet/htmlwidgets sources),fig-altdoesn't apply to widgets, and axe-core doesn't flag unnamed generic divs, soaxe: truegives 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:
Actual behavior
…</script><p>WIDGET-CAPTION daily ED visits</p>— a sibling paragraph, no association. (Same informat: html.)<figure>/<figcaption>, even inside the dashboard card.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-alt→aria-labelon the placeholder<div class="… html-widget …">itself, so a focusable container (leaflet) is announced by name on focus. Reusingfig-altkeeps 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);
figurekeeps 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_caption→wrap_asis_output(options, x), whereoptions[["fig.alt"]]and the placeholder HTML string are both in scope (verified by probing a live render):quarto-cli/src/resources/rmd/patch.R
Lines 97 to 149 in 8577ed1
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
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