quarto-dev / quarto-dev/quarto-cli

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

Open Beginner friendly
#14,844 0 comments 1 reaction 0 assignees View on GitHub

Nobody has claimed this yet.

bug
Dominant language
JavaScript
Stars
6k
Forks
458
Avg merge
1d 9h
Merged PRs (30d)
41

Description

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:

https://github.com/quarto-dev/quarto-cli/blob/abc6a78ed68f9e8bc9d54e27851093bd687a1cb7/src/format/html/format-html-bootstrap.ts#L352-L363

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":

https://github.com/quarto-dev/quarto-cli/blob/471531762d94e7badf0bd2e2f62461be07afc624/src/resources/formats/html/pandoc/toc.html#L1-L6

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...

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in src/format/html/format-html-bootstrap.ts at the useDoubleToc branch that clones the nav, then compare the cloned markup with the aria-labelledby target described in the issue. Render the provided index.qmd with quarto render and verify that the clone uses distinct heading and link ids while both TOCs retain the intended accessible name.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
accessibility, frontend
Issue type
Bug
Difficulty
2/5
Estimated time
1-3 hours
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
86/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.