Skip to content

toc-location: left-body clones the TOC without renaming its ids #14844

Description

@cwickham

I have:

  • searched the issue tracker for similar issues
  • installed the latest version of Quarto CLI
  • formatted my issue following the Bug Reports guide

Bug description

With toc-location: left-body or toc-location: right-body, Quarto renders the
table of contents twice. The useDoubleToc branch clones the whole nav and
renames only the nav itself, from TOC to TOC-body:

if (useDoubleToc) {
// Clone the TOC
// Leave it where it is in the document, and just mutate it
const clonedToc = toc.cloneNode(true) as Element;
clonedToc.id = "TOC-body";
const tocActionsEl = clonedToc.querySelector(".toc-actions");
if (tocActionsEl) {
tocActionsEl.remove();
}
toc.parentElement?.insertBefore(clonedToc, toc);
}

Every other id inside the clone is copied as is. The page then holds two
elements for each id in the TOC: the h2#toc-title heading, and one
a#toc-<section> link per entry. The id attribute must be unique. From the
HTML Standard, The id attribute:

When specified on HTML elements, the id attribute value must be unique
amongst all the IDs in the element's tree and must contain at least one
character.

The duplicate ids are old. #14813 makes one of them load-bearing. That PR labels
the TOC landmark with aria-labelledby="toc-title":

<nav id="$idprefix$TOC" role="doc-toc"$if(toc-title)$ aria-labelledby="$idprefix$toc-title"$endif$>
$if(toc-title)$
<h2 id="$idprefix$toc-title">$toc-title$</h2>
$endif$
$table-of-contents$
</nav>

Both nav elements now point at an id that resolves to one of two elements.
This is not a name bug, because both headings carry the same text. It does turn
an inert duplicate id into an ARIA reference. In axe-core 4.10.3, the version
Quarto vendors, duplicate-id-aria moves from inapplicable to needs review
(impact critical) on the same page.

This came out of review on #14813 (thanks @cderv). The duplicate id predates
that PR — measured below.

Steps to reproduce

---
title: "Left-body TOC"
format:
  html:
    toc: true
    toc-location: left-body
    toc-title: "On this page"
---

## First section

Some text.

## Second section

More text.

Then count the ids in the output:

quarto render index.qmd
grep -c 'id="toc-title"' index.html

Actual behavior

Two elements carry id="toc-title", and each TOC link id appears twice:

<nav id="TOC" role="doc-toc" aria-labelledby="toc-title" class="toc-active">
  <h2 id="toc-title">On this page</h2>
  <ul>
    <li><a href="#first-section" id="toc-first-section" ...>First section</a></li>
...
<nav id="TOC-body" role="doc-toc" aria-labelledby="toc-title">
  <h2 id="toc-title">On this page</h2>
  <ul>
    <li><a href="#first-section" id="toc-first-section">First section</a></li>

A DOM count of duplicated ids on that page gives
toc-title, toc-first-section, toc-second-section — one entry per TOC line,
plus the heading. The count grows with the size of the TOC.

axe-core 4.10.3 over the rendered page, in headless Chromium 151, Firefox 153
and WebKit 26.5:

rendered with duplicate-id-aria landmark-unique
current main inapplicable violation (moderate)
#14813 branch incomplete, critical violation (moderate)

landmark-unique fires either way, so it is not a regression from #14813.
duplicate-id-aria is new, because toc-title only becomes an accessible
reference once aria-labelledby points at it. The rule reports as incomplete
rather than a violation because it carries reviewOnFail: true.

All three engines resolve the reference the same way, to the first element in
tree order — the heading inside nav#TOC. Both landmarks compute the same
accessible name, "On this page". Nothing user-facing breaks today. The only
id-based lookup in Quarto's own JS is
titleSelector: "#toc-title",
which reads document.body.querySelector("#toc-title") and so takes the first
match. Both headings hold identical text, so the sidebar toggle reads correctly
by accident.

Expected behavior

Each id appears once. The clone gets its own heading id, and its
aria-labelledby points at that id:

<nav id="TOC-body" role="doc-toc" aria-labelledby="toc-title-body">
  <h2 id="toc-title-body">On this page</h2>

Both landmarks keep the accessible name "On this page". That shared name is
correct here, and it is what the ARIA Authoring Practices Guide asks for. From
Landmark Regions, Step 3: Label areas:

There is one rare circumstance where providing the same label to multiple
instances of a landmark can be beneficial: the content and purpose of each
instance is identical.

and, in the same document, on navigation landmarks:

If a navigation landmark has an identical set of links as another
navigation landmark on the page, use the same label for each navigation
landmark.

So the fix is the id, not the label. The two TOCs must keep the same name, which
means axe's landmark-unique result stays. That rule is best-practice, not
WCAG, and this is the case the APG carves out.

The duplicated a#toc-<section> link ids are the same fault from the same clone.
They are not ARIA references, and axe's duplicate-id rule is deprecated and
off by default, so they produce no finding. They are still invalid HTML. Worth
handling in the same place, or in a follow-up.

Your environment

  • Positron 1.124.0
  • macOS 26.5.2 (build 25F84)

Reproduced with released Quarto 1.10.18 and with a dev build of 1.11.

An AI assistant helped investigate, grounded in a local clone
(CONTRIBUTING.md, "Using AI tools to investigate").

Quarto check output

Quarto 1.10.18
[✓] Checking environment information...
      Quarto cache location: /Users/charlottewickham/Library/Caches/quarto
[✓] Checking versions of quarto binary dependencies...
      Pandoc version 3.10.0: OK
      Dart Sass version 1.101.0: OK
      Deno version 2.7.14: OK
      Typst version 0.15.1: OK
[✓] Checking versions of quarto dependencies......OK
[✓] Checking Quarto installation......OK
      Version: 1.10.18
      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.5.2
      Path: /Library/Frameworks/R.framework/Versions/4.5-arm64/Resources
      LibPaths:
        - /Users/charlottewickham/Library/R/arm64/4.5/library
        - /Library/Frameworks/R.framework/Versions/4.5-arm64/Resources/library
      knitr: 1.51
      rmarkdown: 2.30
[✓] 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

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions