payloadcms / payloadcms/payload

Admin dashboard: dropdown indicator is focusable while aria-hidden (axe aria-hidden-focus, serious)

Open
#18,139 1 comment 0 reactions 1 assignee View on GitHub

@JessRynkar is already working on this.

Since Sep 10, 2026.

area: accessibility area: ui Bug
Dominant language
TypeScript
Stars
44.8k
Forks
4.2k
Avg merge
2d 21h
Merged PRs (30d)
53

Description

Describe the Bug

On the default admin dashboard, the breadcrumb select's dropdown indicator is a focusable <button> carrying aria-hidden="true". axe reports aria-hidden-focus (serious), and the element genuinely takes keyboard focus, so a keyboard or screen-reader user can land on a control that is hidden from the accessibility tree.

Rendered node on a blank install:

<button class="dropdown-indicator" aria-hidden="true" type="button">
  <svg class="icon icon--chevron dropdown-indicator__icon" …>
</button>

Measured, not inferred: tabIndex is 0, and calling .focus() on it results in document.activeElement === btn (focusReceived: true).

The control is the dashboard breadcrumb ReactSelect rendered by DashboardStepNav (@payloadcms/next/dist/views/Dashboard/Default/ModularDashboard/DashboardStepNav.js, rendered from ModularDashboard/index.client.js). It needs no collections, fields, or configuration — it is on the dashboard of every install.

A likely mechanism, offered as a hypothesis rather than a finding. @payloadcms/ui's DropdownIndicator renders a <button> and spreads react-select's innerProps onto it:

// @payloadcms/ui/dist/elements/ReactSelect/DropdownIndicator/index.js
export const DropdownIndicator = props => {
  const { innerProps: { ref, ...restInnerProps } } = props;
  return _jsx("button", { className: baseClass, ref, ...restInnerProps, … });

react-select supplies aria-hidden: 'true' in innerProps for its indicators, which is correct for its own default non-focusable <div> and becomes a violation once those props land on a focusable <button>. I have verified the rendered result and the focus behaviour; I have not verified that this spread is the source, so please treat the attribution as a starting point rather than a diagnosis.

A sibling defect on the same control is filed separately (the combobox input has no accessible name).

Link to the code that reproduces this issue

https://github.com/rjbashar/payload-a11y-repro

Reproduction Steps
  1. createdb payload_a11y_repro, then cp .env.example .env and set DATABASE_URI / PAYLOAD_SECRET
  2. pnpm install
  3. pnpm build && pnpm starta production build; see the README note, as pnpm dev does not hydrate the admin in this minimal app and the dashboard appears empty
  4. Create the first user:
    curl -X POST http://127.0.0.1:3210/api/users/first-register -H 'Content-Type: application/json' -d '{"email":"repro@example.test","password":"Repro-passw0rd!"}'
  5. Sign in at /admin/login, landing on /admin
  6. In the console:
    const b = document.querySelector('.dashboard-breadcrumb-select button.dropdown-indicator')
    b.outerHTML   // aria-hidden="true"
    b.tabIndex    // 0
    b.focus(); document.activeElement === b   // true
    

verify.mjs in the repo performs all of this headlessly and prints the axe result. Restricted to this rule:

{ "id": "aria-hidden-focus", "impact": "serious", "nodes": 1, "targets": [".dropdown-indicator"] }
Which area(s) are affected?

area: ui, area: accessibility

Environment Info
payload                       3.85.2
@payloadcms/next              3.85.2
@payloadcms/db-postgres       3.85.2
@payloadcms/richtext-lexical  3.85.2
next                          16.3.4
react / react-dom             19.2.6
react-select                  5.9.0  (transitive, via @payloadcms/ui)
node                          26.0.0
axe-core                      4.13.0
database                      PostgreSQL

Versions are pinned and the lockfile is committed, so the reproduction does not drift with @latest.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.