Suffix the ids in the cloned body TOC - #14853
Conversation
#14376) New language keys navigation-{main,section,secondary,page,breadcrumbs}-label label the navbar, sidebar (adaptive), secondary nav, prev/next page nav, and breadcrumbs; the TOC nav is labelled by its localized heading via aria-labelledby (html + revealjs). Defaults omit the word "navigation" per the APG landmark-regions practice, since screen readers announce the role after the label. Note: all-schema-definitions.json also picks up the lagged cache-globals schema from #14735 (regenerated artifacts trail schema changes by one build).
With `toc-location: left-body` or `right-body`, `useDoubleToc` clones the whole TOC `nav` and renamed only the `nav` itself, from `TOC` to `TOC-body`. Every id inside the clone was copied as is, so the page carried two elements for the `toc-title` heading and for each `toc-<section>` link. Ids must be unique in the tree. The duplicates were inert until #14376 labelled the TOC landmark with `aria-labelledby="toc-title"`, which turned one of them into an ARIA reference: axe-core's `duplicate-id-aria` went from inapplicable to needs-review (critical) on these pages. Suffix every id the clone carries with `-body`, and repoint its `aria-labelledby` at the renamed heading. Nothing outside the nav references these ids, and the originals stay on the sidebar copy, so `getElementById` resolves exactly as it did before. Both landmarks keep the accessible name from the TOC title. That shared name is correct here: the APG asks for the same label on two navigation landmarks that hold an identical set of links, so axe's `landmark-unique` best-practice result stays and should not be fixed by renaming the labels.
✅ Snyk checks have passed. No issues have been found so far.
💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse. |
…c-duplicate-ids-2 # Conflicts: # src/config/constants.ts # src/config/types.ts # src/project/types/website/website-navigation.ts # src/resources/editor/tools/vs-code.mjs # src/resources/editor/tools/yaml/all-schema-definitions.json # src/resources/editor/tools/yaml/web-worker.js # src/resources/editor/tools/yaml/yaml-intelligence-resources.json # src/resources/language/_language.yml # src/resources/projects/website/templates/nav-before-body.ejs # src/resources/projects/website/templates/sidebar.ejs # src/resources/schema/definitions.yml # src/resources/schema/json-schemas.json # src/resources/types/schema-types.ts # src/resources/types/zod/schema-types.ts # tests/docs/smoke-all/website/nav-landmark-labels/_quarto.yml # tests/docs/smoke-all/website/nav-landmark-labels/index.qmd # tests/docs/smoke-all/website/nav-landmark-labels/no-navbar.qmd # tests/docs/smoke-all/website/nav-landmark-labels/override.qmd
| // Ids must be unique, so the clone can't keep the originals: suffix | ||
| // every id it carries, and repoint its aria-labelledby at the renamed | ||
| // heading. Nothing outside the nav references these ids, and the | ||
| // originals stay on `toc`, so existing lookups resolve as before. | ||
| const clonedLabelId = clonedToc.getAttribute("aria-labelledby"); | ||
| const clonedIdEls = clonedToc.querySelectorAll("[id]"); | ||
| for (let i = 0; i < clonedIdEls.length; i++) { | ||
| const clonedIdEl = clonedIdEls[i] as Element; | ||
| clonedIdEl.id = `${clonedIdEl.id}-body`; | ||
| } | ||
| if (clonedLabelId) { | ||
| clonedToc.setAttribute("aria-labelledby", `${clonedLabelId}-body`); | ||
| } |
There was a problem hiding this comment.
The suffix isn't guaranteed unique against the rest of the document. Pandoc dedupes identical heading slugs, but it doesn't check whether a slug already matches another heading's slug plus -body.
# Setup produces toc-setup. # Setup Body produces toc-setup-body, already sitting in the sidebar TOC. Suffixing the clone's toc-setup gives toc-setup-body too, colliding with the second heading's own entry, and we're back to the duplicate-id-aria failure this PR closes.
I don't know if this case can happen a lot or if this is really edge case ? What do you think ?
If this is possibly, I think this needs a real uniqueness check against the existing ids rather than a fixed suffix, plus a fixture with a heading pair that triggers this (something like Setup / Setup Body) so it doesn't come back silently.
| const tocActionsEl = clonedToc.querySelector(".toc-actions"); | ||
| if (tocActionsEl) { | ||
| tocActionsEl.remove(); | ||
| } |
There was a problem hiding this comment.
Code above runs the id-suffix loop over the whole cloned nav, including the .toc-actions subtree, before it's removed here. This is not wrong, but just wasted work if that subtree carries ids — we could just remove .toc-actions first and so do the toc id processing after. What do you think ?
Closes #14844.
Description
With
toc-location: left-bodyorright-body, theuseDoubleTocbranch clonesthe whole table-of-contents
navand renames only thenavitself, fromTOCto
TOC-body. Every id inside the clone was copied as is. A page thereforecarried two elements for the
toc-titleheading, and two for eachtoc-<section>link. The number of duplicates grows with the size of the TOC.Ids must be unique in the tree (HTML Standard, the
idattribute).The duplicates were inert until #14376 labelled the TOC landmark with
aria-labelledby="toc-title". That reference makes one of them load-bearing.This PR suffixes every id the clone carries with
-body, and repoints theclone's
aria-labelledbyat the renamed heading:Why every id, and not only the heading
The heading is the one id that ARIA references, so a minimal fix could rename
only that. I widened it because nothing outside the
navreads the link ids.They come from Pandoc's TOC writer, and a search across
src/for"toc-",#toc-$, andgetElementById("tocfinds no consumer in Quarto's JS, SCSS, orLua filters.
The originals stay on the sidebar copy, which is first in tree order. So
document.getElementById("toc-section-1")returned the sidebar element beforethis change and returns the same element after it. Third-party CSS or JS that
targets these ids sees no difference.
The accessible name does not change
Both landmarks keep the name from the TOC title. That shared name is the correct
outcome here, and it is what the ARIA Authoring Practices Guide asks for
(Landmark Regions, Step 3):
axe-core's
landmark-uniquestill reports these twonavelements. That is abest-practicerule, not WCAG, and this is the case the APG carves out. Do not"fix" it by giving the two TOCs different labels — that would make the page
worse for screen reader users. It belongs in an axe baseline.
Measured effect
axe-core 4.10.3, the version Quarto vendors, over a rendered
toc-location: left-bodypage:duplicate-id-arialandmark-uniquemainduplicate-id-arianow passes rather than being inapplicable, because thereference exists and resolves to one element.
Testing
Extended the two existing fixtures for this layout,
tests/docs/smoke-all/issues/3473-toc-side-body/{left-body,right-body}.qmd,with positive assertions for the suffixed ids and negative assertions that the
clone no longer carries
toc-titleortoc-section-1.Both pass. With the change to
format-html-bootstrap.tsreverted,left-body.qmdfails, so the assertions catch the fault they describe.Also ran locally, all passing:
3473-toc-side-body/{body,left,right}.qmd— the layouts that render one TOCare unaffected.
accessibility/toc-nav-label.qmd— the fixture Give every website and booknavlandmark a distinct, localizable label #14813 adds.Checked by hand:
tree, for
nav#TOCandnav#TOC-body.right-bodybehaves the same asleft-body.toc-title: ""produces no heading and noaria-labelledby. The guard holds,the link ids are still suffixed, and no id repeats.
toc-title: falsefails YAML validation on the base branch too, so no TOCrenders without a heading through metadata.
I did not run the full suite locally. CI covers it.
Checklist
I have (if applicable):
The ids are internal, and no documented option changes, so there is nothing to
document on quarto.org.
AI-assisted PR