Skip to content

a11y: navbar and sidebar tools declared with only an icon: have no accessible name #14828

Description

@cderv

The documented way to declare navbar and sidebar tools uses icon: alone, with no text:. That renders links with an empty accessible name, which axe-core reports as link-name.

Taking the example straight from https://quarto.org/docs/websites/website-navigation.html#navbar-tools:

project:
  type: website

website:
  title: "Tools repro"
  navbar:
    left:
      - href: index.qmd
        text: Home
    tools:
      - icon: bluesky
        href: https://bsky.app
      - icon: github
        menu:
          - text: Source Code
            href: https://code.com
          - text: Report a Bug
            href: https://bugs.com

format:
  html:
    axe:
      output: json

produces

<a href="https://bsky.app" title="" class="quarto-navigation-tool px-1" aria-label=""><i class="bi bi-bluesky"></i></a>
<a href="" title="" id="quarto-navigation-tool-dropdown-0" class="quarto-navigation-tool dropdown-toggle px-1" data-bs-toggle="dropdown" aria-expanded="false" role="link" aria-label=""><i class="bi bi-github"></i></a>

The icon is drawn by a class on an empty <i>, so there is no text node to fall back on. Chrome's accessibility tree announces both as link "", and axe-core reports link-name (serious, wcag2a / wcag244 / wcag412) on three nodes for that config: the navbar tool, the navbar dropdown tool, and the sidebar tool. Sidebar tools go through the same template, so website: sidebar: tools: is affected the same way, and the sidebar example in the docs has the same shape.

aria-label="" is worse than no attribute at all, since an empty aria-label blanks the name rather than letting anything else supply one. Both attributes are interpolated from tool.text, which is unset here:

<% toolDropdownId = 'quarto-navigation-tool-dropdown-' + toolCount %>
<% toolCount = toolCount + 1 %>
<div class="dropdown">
<a href="<%- tool.href %>" title="<%- tool.text %>" id="<%- toolDropdownId %>" class="quarto-navigation-tool dropdown-toggle px-1" data-bs-toggle="dropdown" aria-expanded="false" role="link" aria-label="<%- tool['aria-label'] || tool.text %>"<%= tool.target ? ` target="${tool.target}"` : "" %>><i class="bi bi-<%- tool.icon %>"></i></a>
<ul class="dropdown-menu<%- dropDownAlignClz %>" aria-labelledby="<%- toolDropdownId %>">
<% tool.menu.forEach(item => { %>
<li>
<a class="dropdown-item <%- className %>-item" href="<%- item.href %>"<%= item.target ? ` target="${item.target}"` : "" %>>
<% if (item.icon) { %>
<i class="bi <%- item.icon %> pe-1"></i>
<% } %>
<%- item.text %>
</a>
</li>
<% }) %>
</ul>
</div>
<% } else { %>
<a href="<%- tool.href %>" <%= tool.rel ? `rel="${tool.rel}"` : "" %> title="<%- tool.text %>" class="quarto-navigation-tool px-1" aria-label="<%- tool['aria-label'] || tool.text %>"<%= tool.target ? ` target="${tool.target}"` : "" %>><i class="bi bi-<%- tool.icon %>"></i></a>
<% } %>
<% }) %>

Authors have little reason to set text: on a tool, since it never renders as visible text, only as the title tooltip. aria-label: is accepted on the item and does fix it, but nothing points authors there. Note the same lines already avoid emitting empty rel and target, so title and aria-label look like they were just missed in that pass.

We could omit both attributes when there is no value, and fall back to a name derived from the icon when neither text: nor aria-label: is given, so that the documented configuration produces a named link by default.

Related: #14380 and #14655 are the same axe rule on different elements, and #14373 collects a batch of axe-core findings on Quarto sites.

cc @cwickham as I don't know if you spotted this one, but 🤖 reported it to me while testing

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions