Add skip-to-content link to Bootstrap-themed HTML output - #14685
Conversation
✅ 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. |
|
@cwickham, we should figure out how to document this on quarto-web. |
cwickham
left a comment
There was a problem hiding this comment.
I reviewed this against WCAG technique G1 (the reference technique for SC 2.4.1 Bypass Blocks): manually in Safari with VoiceOver, and with automated Chromium checks (Playwright, driven by real key presses). The automated checks covered four outputs, rendered from this PR's own test fixtures where possible: a standalone document (issue-14684.qmd), page-layout: custom (issue-14684-custom-layout.qmd), a website page with navbar and sidebar (the site-navigation fixture from render-skip-link.test.ts), and dark themes (darkly, plus a light/dark toggle — new documents, since the tests here do not cover dark themes).
All five steps of G1's test procedure pass on those outputs:
- The link is the first focusable control. The first Tab reaches it on every page, before the navbar and sidebar.
- The link text communicates the target ("Skip to main content", localizable via the new
skip-to-contentkey). - The link is visible when it has keyboard focus. It renders as a pill at the top left, above the fixed navbar, in both light and dark themes.
- Activation moves focus to the main content. The
tabindex="-1"on the target makes this work in Safari, which does not move focus on in-page links otherwise. - After activation, focus is on the main content. The next Tab lands on the first link inside the content, and bypasses the navbar, sidebar, and TOC.
Multi-page dashboards break
The skip link empties the content area of any dashboard that has more than one page.
To reproduce
- Render a dashboard with two pages (for example,
tests/docs/smoke-all/2026/07/13/issue-14684-dashboard.qmd). - Press Tab, then press Enter.
The content area goes blank on every page, including the first. The URL now ends in #quarto-document-content, so a reload or a shared link shows the blank state again.
Cause
The skip link sets the URL hash to #quarto-document-content. Browsers fire popstate on every hash change. The dashboard popstate handler passes the new hash to QuartoDashboardUtils.showPage() (quarto-dashboard.js#L120-L123). showPage() removes active from every nav tab and every .tab-pane, and only re-activates a pane that matches the hash (quarto-dashboard.js#L242-L266). No page matches #quarto-document-content, so all pages stay hidden. Focus does move to the content container, as the PR intends, but the container is now empty.
Possible fixes
- In the
popstatehandler, and in the hash processing on load, ignore a hash that is not a page.QuartoDashboardUtils.isPage()(quarto-dashboard.js#L229-L232) already does this test. The load-time guard matters too: without it, a URL that ends in#quarto-document-contentopens as a blank dashboard. - Or make
showPage()keep the current page when no page matches the hash.
The automated checks were Playwright scripts written with Claude Code, grounded in a local clone of the repository (per CONTRIBUTING.md, "Using AI tools to investigate").
Inject a "Skip to main content" link as the first element of the body for all bootstrap-themed HTML output (documents, websites, books, dashboards) so keyboard users can bypass navigation blocks (WCAG 2.4.1 Bypass Blocks). The link is visually hidden until focused, targets the main content container (assigning an id and tabindex="-1" where missing), and its text is localizable via the new language key skip-to-content. Dashboards additionally mark their content container with role="main". Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Regenerated with `dev-call build-artifacts`, run twice: the schema registry that produces all-schema-definitions.json is partly seeded from the yaml-intelligence files on disk, so a new key reaches that file only on the second build. Part of this diff is pre-existing drift, not caused by this branch. The committed artifacts on main are stale relative to src/resources/schema, and all-schema-definitions.json is nondeterministic in its duplicate description counts. Running build-artifacts on an unmodified checkout of main produces the same four modified files.
On a dashboard the skip link was a plain anchor jump, so the browser pushed a history entry for "#quarto-document-content". That entry names no page, so Back landed on a dead step: the URL changed but the visible page did not, and the reader had to press Back twice to leave the page they were on. Handle the click instead: move focus to the target and leave the URL alone. Back and Forward then behave as they do without the skip link, and a page hash such as "#sales" survives using the link, so the shareable URL still works. Without JS the link is still an ordinary in-page anchor.
be1a4da to
04a2386
Compare
|
@cscheid I picked this up. The multi-page dashboard problem from my review is fixed, and testing turned up a second one, so I have split the work. The dashboard bugs were not caused by this PR. Both reproduce on
Both are fixed in #14820, and this PR is now stacked on it. Merge #14820 first. What changed here. One behavior change and some rebase housekeeping:
On documenting this on quarto-web — agreed, and I have not written it yet. I would rather land the behavior first, since the opt-out option discussed in #14684 would change what the docs say. Happy to take the quarto-web PR once the shape is settled. Your original commit is preserved; nothing of the implementation changed apart from the dashboard click handling. |
Closes #14684.
What this does
Adds a "Skip to main content" link as the first element of
<body>for all Bootstrap-themed HTML output — standalone documents, websites, books, manuscripts, and dashboards — so keyboard and switch users can bypass navigation blocks (WCAG 2.2 SC 2.4.1 "Bypass Blocks", Level A).theme: none/minimaloutput is untouched.#quarto-document-content→ first<main>→div.page-layout-custom(dashboards, standalone custom layouts) →#quarto-content(website custom layouts). If the target has no id it is assignedquarto-document-content, and it getstabindex="-1"so activating the link moves focus, not just scroll position..visually-hidden-focusablehelper (already compiled into every theme); when focused it appears as a fixed-position pill above the fixed/headroom navbar ($zindex-skip-link: 1070), styled from theme variables so it works in light and dark themes.languagekeyskip-to-content, with seeded translations for fr, de, es, pt, pt-BR, it, ja, zh, zh-TW, nl (other languages fall back to English; regional variants inherit their base language).role="main", addressing the missing main-landmark note in the issue.Dashboards need JavaScript for the link
On a dashboard the link cannot be a plain anchor jump. A dashboard uses the URL hash to select a page, so jumping to
#quarto-document-contentpushes a history entry that names no page. Back then landed on a dead step: the URL changed but the visible page did not, and the reader had to press Back twice.The dashboard now handles the click, moves focus to the target, and leaves the URL alone. Back and Forward behave as they do without the link, and a page hash such as
#salessurvives using the link, so the shareable URL still works. Without JavaScript the link is still an ordinary in-page anchor.Everything else is unchanged: no JavaScript is involved for documents, websites, books, or manuscripts.
Behavior
On any page: first Tab reveals the link; Enter moves focus to the main content; the next Tab lands on the first focusable inside the content, bypassing navbar/sidebar/TOC entirely.
Tests
theme: none(asserts no link),page-layout: custom.dashboard-skip-link.spec.ts: the link is the first tab stop and moves focus into the content; it adds no history entry, so one Back returns to the previous page; a page hash survives using it. Passing on Chromium, Firefox, and WebKit. Verified against a build without the click handler: the two history/hash tests fail, the focus test passes either way.lang: fr).Notes for review
news/changelog-1.10.mdtonews/changelog-1.11.md, sincemainhas since moved to 1.11.mainrather than rebased, so it no longer carries the stale editor-tooling churn the earlier version picked up. Some drift remains:dev-call build-artifactson an unmodified checkout ofmainalready modifies the same four files, because the committed artifacts are stale relative tosrc/resources/schemaandall-schema-definitions.jsonis nondeterministic in its duplicate-description counts.skip-link: false) will be filed and linked here, per discussion in No skip-to-content link: keyboard users must tab through all navigation on every page (WCAG 2.4.1, Level A) #14684 review.AI-assisted PR