Skip to content

Add skip-to-content link to Bootstrap-themed HTML output - #14685

Open
cscheid wants to merge 3 commits into
fix/14818-dashboard-non-page-hashfrom
fix/14684-skip-to-content
Open

Add skip-to-content link to Bootstrap-themed HTML output#14685
cscheid wants to merge 3 commits into
fix/14818-dashboard-non-page-hashfrom
fix/14684-skip-to-content

Conversation

@cscheid

@cscheid cscheid commented Jul 14, 2026

Copy link
Copy Markdown
Member

Closes #14684.

Stacked on #14820. This PR targets fix/14818-dashboard-non-page-hash, not main. The diff shown here is only the skip-link work. #14820 fixes two dashboard hash bugs that predate this PR and that this PR would otherwise expose on every dashboard. Merge #14820 first.

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

  • The link is injected by the bootstrap HTML postprocessor, so a single implementation covers every Bootstrap-based output shape (dashboards call the base HTML format extras, so they get it too). theme: none / minimal output is untouched.
  • Target resolution falls back gracefully: #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 assigned quarto-document-content, and it gets tabindex="-1" so activating the link moves focus, not just scroll position.
  • Visually hidden via Bootstrap's .visually-hidden-focusable helper (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.
  • Link text is localizable via a new language key skip-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).
  • Dashboards additionally mark their content container with 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-content pushes 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 #sales survives 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

  • smoke-all tests: document with TOC, dashboard, theme: none (asserts no link), page-layout: custom.
  • site test asserting the link and target on a navbar+sidebar website page.
  • New Playwright spec 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.
  • Manually verified with browser keyboard navigation (docs, website, dashboard, dark theme, lang: fr).

Notes for review

  • Reviewed against WCAG technique G1 in Add skip-to-content link to Bootstrap-themed HTML output #14685's review thread. All five steps of its test procedure pass.
  • The changelog entry moved from news/changelog-1.10.md to news/changelog-1.11.md, since main has since moved to 1.11.
  • The build-artifacts commit was regenerated from current main rather than rebased, so it no longer carries the stale editor-tooling churn the earlier version picked up. Some drift remains: dev-call build-artifacts on an unmodified checkout of main already modifies the same four files, because the committed artifacts are stale relative to src/resources/schema and all-schema-definitions.json is nondeterministic in its duplicate-description counts.
  • A follow-up issue proposing an opt-out option (e.g. 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
  • AI tool used: Claude Code
  • Codebase grounding: local clone
  • Human review: I have reviewed, tested, and verified the AI-generated content before submitting.

@posit-snyk-bot

posit-snyk-bot commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues
Licenses 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

@cscheid

cscheid commented Jul 14, 2026

Copy link
Copy Markdown
Member Author

@cwickham, we should figure out how to document this on quarto-web.

@cwickham cwickham left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. The link is the first focusable control. The first Tab reaches it on every page, before the navbar and sidebar.
  2. The link text communicates the target ("Skip to main content", localizable via the new skip-to-content key).
  3. 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.
  4. 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.
  5. 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

  1. Render a dashboard with two pages (for example, tests/docs/smoke-all/2026/07/13/issue-14684-dashboard.qmd).
  2. 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 popstate handler, 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-content opens 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").

cscheid and others added 3 commits August 27, 2026 15:08
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.
@cwickham
cwickham force-pushed the fix/14684-skip-to-content branch from be1a4da to 04a2386 Compare August 27, 2026 22:09
@cwickham
cwickham changed the base branch from main to fix/14818-dashboard-non-page-hash August 27, 2026 22:10
@cwickham

Copy link
Copy Markdown
Member

@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 main and in released Quarto 1.10.18 with no skip link present. They now have their own issues and their own PR:

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 dashboards the skip link now moves focus in JavaScript instead of jumping to the hash. As a plain anchor it pushed a history entry naming no page, so Back landed on a dead step and had to be pressed twice. A nice side effect: a page hash such as #sales now survives using the link. Other formats are unchanged and still involve no JavaScript.
  • Rebased onto current main (69 commits had accumulated) and moved the changelog entry to 1.11.
  • Dropped your build-artifacts commit and regenerated instead, which removes the stale editor-tooling churn you flagged. Some drift is unavoidable: dev-call build-artifacts on an unmodified main already touches the same four files.
  • Added a Playwright spec for the dashboard skip link, plus the first dashboard specs in the suite over in Fix dashboard URL hash handling #14820.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

No skip-to-content link: keyboard users must tab through all navigation on every page (WCAG 2.4.1, Level A)

3 participants