Skip to content

Make website sidebar section toggles keyboard-operable buttons - #14826

Open
cwickham wants to merge 2 commits into
mainfrom
fix/sidebar-disclosure
Open

Make website sidebar section toggles keyboard-operable buttons#14826
cwickham wants to merge 2 commits into
mainfrom
fix/sidebar-disclosure

Conversation

@cwickham

@cwickham cwickham commented Aug 28, 2026

Copy link
Copy Markdown
Member

Description

Fixes #12116
Fixes #4935

The expandable sections in the website left sidebar were <a> elements with no
href, so the keyboard could not reach them (#12116). They also carried
role="navigation", which made each section an unnamed nested navigation
landmark rather than a named control, and split the label and the chevron into
two tabstops (#4935).

Relationship to #14813

#14813 gives every real <nav> landmark a distinct label and closes #14376. Its
"Out of scope" note defers the invalid role="navigation" on the secondary-nav
and sidebar section toggles; this PR removes both. The two are complementary:
#14813 names the genuine landmarks, this PR deletes the fake ones.

They overlap in two files. nav-before-body.ejs auto-merges — #14813 edits the
<nav> open tags, this PR edits the <a> toggles below them. news/changelog-1.11.md
conflicts, because both add an entry to ## Accessibility; keep both entries.

What changed

A section without its own page now renders one <button> holding both the
label and the chevron — one tabstop, the button role, and a name from the label
text:

<button type="button" class="sidebar-item-text sidebar-item-toggle sidebar-link text-start collapsed"
        data-bs-toggle="collapse" data-bs-target="#quarto-sidebar-section-1"
        aria-controls="quarto-sidebar-section-1" aria-expanded="false">
  <span class="menu-text" id="quarto-sidebar-section-1-label">Group</span>
  <i class="bi bi-chevron-right" aria-hidden="true"></i></button>
<ul id="quarto-sidebar-section-1" aria-labelledby="quarto-sidebar-section-1-label" ...>

A section that is also a page keeps its link and gets a <button> chevron
beside it, named aria-label="Toggle section: <title>".

Every disclosed list now carries aria-labelledby pointing at its own section
label, so a screen reader says which section the list belongs to and how many
items it holds.

Why a section with a page keeps two tabstops

The guidance on #4935 is one control with one tabstop. That works for a section
with no page of its own, and this PR does exactly that.

It cannot work for a section that is also a page: the title navigates and the
chevron expands, and one activation cannot do both. Merging them would mean a
user can no longer expand a section without leaving the current page — a
behavior change for every existing site that writes section: page.qmd. So the
link stays and the chevron becomes a real <button>, named per section so it is
distinct from the other chevrons. The name reuses the existing translatable
toggle-section key, so all 34 shipped translations work with no new string.

The role="navigation" finding has changed

#4935 calls the ARIA invalid and cites aria-allowed-attr from a 2023 axe run.
Where that stands today:

  • ARIA 1.1 listed navigation under Inherits into Roles for
    aria-expanded, via the abstract section role. Valid then.
  • ARIA 1.2 dropped it: section no longer supports aria-expanded, and both
    the Used in Roles and Inherits into Roles lists for
    aria-expanded exclude
    navigation. The premise of Left-nav menu expand/collapse should be buttons/use disclosure pattern (a11y issue) #4935 holds.
  • axe-core 4.10.3 does not report it, because its own role table still has
    navigation: { allowedAttrs: ['aria-expanded'] }. That silence is an axe policy
    choice, not evidence the markup is valid.

The worse problem was a different one: navigation takes its name from the
author only, so the visible text "Group" was never the control's accessible name.
Chrome computed it as navigation "".

Removing role="navigation" also deletes the spurious landmark each section
added, which is one source of the landmark-unique violation axe did report.
It is not the only source — see the note on #14813 below.

Verification

Measured on a site covering both shapes, two depths, and both collapse states at
a 1440px viewport. Chrome's computed accessibility tree:

Before After
navigation "" button "Group" expanded=false
navigation "Toggle section" button "Toggle section: landing" expanded=false
list unnamed list "Group"
  • Tab reaches every section toggle: 0 of 3 before, 3 of 3 after. Enter and
    Space both toggle, and aria-expanded tracks the real state.
  • axe-core 4.10.3 over every page of the test sites, in both the collapsed and
    the expanded sidebar state, at 1440px and 800px: 0 violations. Scanning the
    expanded state matters, because a collapsed section is display: none and its
    disclosed list is never evaluated. On a website page landmark-unique goes
    from 1 to 0. On a book page it goes from 2 nodes to 1: books emit further
    unnamed <nav> landmarks (#TOC, .page-navigation), which Give every website and book nav landmark a distinct, localizable label #14813 fixes.
    Note that axe is not the detector that matters here — it says nothing about
    keyboard operability, and saw nothing at all until the scan ran at 992px or
    wider, because the sidebar is display: none below that width.
  • Checked with VoiceOver on macOS: the controls are announced as buttons with
    their section name and expanded state, and keyboard navigation is usable.
  • Styling parity with 1.10, checked across 8 themes (default, cosmo,
    sketchy, darkly, vapor, litera, journal, minty). The merged button
    computes identically to the <a> it replaced on 21 of 22 properties, including
    font, color, background, border and padding; the exception is display: block
    flex, which is the point. Row heights, row positions and the chevron's
    right edge are unchanged. That is why the button uses align-items: baseline
    rather than centercenter renders 1-2px shorter per section, which shifts
    every row below it. (The section label computes body-grey rather than
    link-blue in both old and new: the old <a> had no href, so link styling
    never applied to it.)
  • Unchanged: the chevron rotation, the active item lookup
    (li.sidebar-item a.activeactive only ever lands on a real link), the
    mobile sidebar below 992px, and collapse-level plus the auto-expanded active
    section at depth 1 and 2, in both websites and books.

New smoke tests in tests/docs/smoke-all/website/sidebar-disclosure/ cover both
shapes and both collapse states, and were confirmed to fail against the old
markup. The existing tests/smoke/site suite passes (34 tests).

Also in this PR

nav-before-body.ejs carried role twice on one element
(role="navigation" ... role="link"), which is invalid HTML. Both are removed,
with the ARIA that duplicated the real toggle button beside it. That element is a
decorative mouse target; the adjacent button.quarto-btn-toggle is the
keyboard-operable control and already works. Making it a button, or removing it
as redundant, needs a behavior decision and is left out.

Checklist

I have (if applicable):

  • referenced the GitHub issue this PR closes
  • updated the appropriate changelog in the PR
  • ensured the present test suite passes
  • added new tests
  • created a separate documentation PR in Quarto's website repo and linked it to this PR
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 Aug 28, 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.

@cwickham
cwickham force-pushed the fix/sidebar-disclosure branch from ccdcb89 to 5c710d6 Compare August 28, 2026 16:33
The expandable sidebar sections were <a> elements with no href, so the
keyboard could not reach them at all, and they carried role="navigation",
which made each one an unnamed nested navigation landmark instead of a
named control.

Sections without their own page now render one <button> that holds both
the label and the chevron, so there is a single tabstop. Sections that do
have a page keep their link and get a <button> chevron beside it, named
after the section. Every disclosed list is named with aria-labelledby
pointing at its own section label.

Also removes the duplicated `role` attribute from the mobile sidebar
toggle in nav-before-body.ejs, which was invalid HTML.

Fixes #12116
Fixes #4935
@cwickham
cwickham force-pushed the fix/sidebar-disclosure branch from 5c710d6 to 216b120 Compare August 28, 2026 16:47
@cwickham
cwickham marked this pull request as ready for review August 28, 2026 18:43
@cwickham
cwickham requested a review from cderv August 28, 2026 18:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants