Skip to content

fix(a11y): resolve accessibility issues in JsonViewer, SearchBox, and SelectOnClick - #2800

Open
ApurveKaranwal wants to merge 2 commits into
Redocly:mainfrom
ApurveKaranwal:fix/a11y-improvements
Open

fix(a11y): resolve accessibility issues in JsonViewer, SearchBox, and SelectOnClick#2800
ApurveKaranwal wants to merge 2 commits into
Redocly:mainfrom
ApurveKaranwal:fix/a11y-improvements

Conversation

@ApurveKaranwal

Copy link
Copy Markdown

What/Why/How?

  • What: Key interactive components in Redoc (JSON tree collapser buttons, Search Box clear icon, and SelectOnClick wrapper) were not keyboard-accessible or screen-reader friendly.
  • Why: Users relying on assistive technologies or navigating with a keyboard alone could not focus on, activate, or understand the states (expanded/collapsed) of these components.
  • How:
    • JSON Viewer: Updated the collapser button HTML generation to include aria-expanded and descriptive aria-label attributes. Handled keydown (Enter) triggers inside JsonViewer.tsx and called event.preventDefault() to prevent duplicate execution from the browser's synthetic click on <button> elements. Wrapped handlers in React.useCallback to satisfy ESLint warnings.
    • Search Box: Added tabIndex={0}, role="button", and aria-label="Clear search" to the clear search icon, alongside an onKeyDown handler listening for Enter/Space. Annotated search result box with role="menu", an dynamic results-found aria-label, and aria-live="polite" for the empty-results state.
    • SelectOnClick: Added tabIndex={0}, role="button", and aria-label="Select all text", enabling keyboard activation via Enter or Space.
    • Troubleshooting Docs: Updated the troubleshooting instructions in the README.md to help developers easily diagnose rendering or search issues.

Reference

Resolves accessibility gaps (WCAG 2.1 AA) in interactive components.

Tests

  • Added Jest unit tests inside src/components/__tests__/JsonViewer.tsx to verify:
    • Collapser elements initialize with proper default aria-expanded and aria-label values.
    • Expanding and collapsing updates the ARIA attributes properly.
    • Pressing Enter correctly toggles the state of collapsers.
  • Verified that the full test suite runs and passes (npm run unit).

##Fixes #2799

Screenshots (optional)

N/A (semantic markup changes)

Check yourself

  • Code is linted
  • Tested
  • All new/updated code is covered with tests

@ApurveKaranwal
ApurveKaranwal requested a review from a team as a code owner June 2, 2026 17:52
- Replace generic list markup with role='tree', role='group', role='treeitem'
- Set aria-expanded on li[role='treeitem'] elements for collapsible nodes
- Sync aria-expanded on both collapser button and parent treeitem on toggle
- Add Space key support alongside Enter for collapser keyboard activation
- Add unit tests for ARIA roles, Enter key toggle, and Space key toggle
- Keep SearchBox clear button and SelectOnClick as separate button/focus fixes
@ApurveKaranwal

Copy link
Copy Markdown
Author

@AlexVarchuk please review my PR, and let me know if anything needs to be changed.

@displague displague left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

(🤖 Claude Opus 5) Not a maintainer — just a fellow contributor who has been working in jsonToHtml.ts on #2697, so this landed in familiar territory. Thanks for taking this on; a11y work on a JSON tree renderer is genuinely fiddly and there is real substance here. I checked the branch out and ran it, so the notes below are verified rather than guesses.

Things this gets right

It fixes a real bug that nobody had noticed. On main, the collapser aria-label uses level > maxExpandLevel + 1 while the collapsed class uses level > maxExpandLevel — off by one. Rendering {name:'x', nested:{a:1}} on main gives aria-label="collapse" on both collapsers, even though nested really is collapsed. Aligning both to the same condition is the correct fix, and expand object / collapse array is a nice improvement over the bare verb.

It deletes genuinely dead code. The old focus listener tested event.key === 'Enter', but FocusEvent has no key, and focus does not bubble up to the container either. It could never have fired.

A few other details I appreciated: isCollapsible correctly omits aria-expanded on leaf nodes rather than emitting false (easy to get wrong); type="button" avoids accidental form submission; and the non-null assertions became real null guards. Full suite passes — 279 tests, 27 suites.

Two larger things

The two new keyboard tests do not exercise the handler. Details inline, but briefly: the root collapser already carries aria-label="collapse object" and aria-expanded="true" on mount, and those are exactly the two assertions. I commented out the keydown listener registration and all 8 tests still passed.

role="tree" commits to an interaction model that is not implemented here. Details inline. Short version: li[role="treeitem"] has no tabindex, the only focusable element is the button.collapser nested inside the treeitem, and there is no Arrow/Home/End navigation. The pre-existing "arrow key navigation" test covers horizontal scrolling of the container, not tree traversal. My suggestion is to drop the tree/treeitem/group roles and keep the <button aria-expanded> disclosure pattern, which is well supported and which the rest of this PR already implements correctly. That would also shrink the diff a fair bit.

Smaller notes

  • aria-live="polite" on the no-results box (line 201) probably will not announce. Live regions generally need to be in the DOM before their content changes; here the element and its text are created together. An always-rendered empty region that you fill in tends to work better.
  • role="menu" on the results box implies a command menu with arrow-key navigation. Search suggestions are conventionally combobox + role="listbox"/option with aria-activedescendant. The children do render role="menuitem", so it is at least internally consistent — just probably not the pattern you want.
  • Hardcoded English. "Clear search", "Select all text", "Search results: N results found", and expand object / collapse array bypass l() in src/services/Labels.ts, which is how Redoc localizes UI text (noResultsFound is already in there). Since these strings are now the primary text screen-reader users hear, being untranslatable matters more than it would for a tooltip. The result${n !== 1 ? 's' : ''} pluralization has the same issue. Worth adding label keys.
  • Scope: the README troubleshooting section and CDN rewrite are unrelated to a11y, and the rewrite also removes the 1.x CDN URLs. Splitting them into their own PR would likely get both merged faster.
  • Minor: isCollapsible re-implements the constructor === Array / constructor === Date / typeof === 'object' dispatch that valueToHTML already does — could share a helper. And collapsedClass inside both loops is the same expression as the outer collapsed, since level does not change in the loop body.

None of this touches the core insight, which is sound: these controls were missing aria-expanded and the labels were wrong. Mostly I would scope down to that and land it.

});
component.update();

expect(component.html()).toContain('aria-label="collapse object"');

@displague displague Sep 4, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

(🤖 Claude Opus 5) These two assertions hold on a freshly-mounted component, before any key is dispatched — the root collapser is expanded by default, so aria-label="collapse object" and aria-expanded="true" are both already present somewhere in component.html().

I confirmed it: I commented out node?.addEventListener('keydown', keydownListener) in JsonViewer.tsx and re-ran this file. All 8 tests still passed, including this one and the Space variant.

The fix is to assert against the specific element you dispatched on rather than the whole HTML string:

const collapser = component.getDOMNode()
  .querySelector('button.collapser[aria-label="expand object"]')!;
// ... dispatch Enter on `collapser` ...
expect(collapser.getAttribute('aria-expanded')).toBe('true');
expect(collapser.getAttribute('aria-label')).toBe('collapse object');

Worth noting for the manual check too: jsdom does not implement a button's activation behavior for synthetic KeyboardEvents, so this suite cannot detect a double-toggle if preventDefault() ever fails to suppress the browser's click.

Comment thread src/utils/jsonToHtml.ts
level = 1;
let output = '';
output += '<div class="redoc-json">';
output += '<div class="redoc-json" role="tree">';

@displague displague Sep 4, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

(🤖 Claude Opus 5) I would push back gently on adopting the tree roles at all in this PR.

The ARIA tree pattern expects treeitems to be focusable via a roving tabindex with Arrow/Home/End navigation, and expects treeitems not to contain nested focusable widgets. What actually renders here is:

<div class="redoc-json" role="tree">
  <code>
    <button class="collapser" aria-expanded="true"></button>{
    <ul class="obj collapsible" role="group">
      <li role="treeitem" aria-expanded="false">
        "nested": <button class="collapser" aria-expanded="false"></button>{ ... }

Three issues with that:

  1. No li[role="treeitem"] gets a tabindex, and there is no arrow-key traversal in this PR or on main — the existing "arrow key navigation" test covers horizontal scrolling of the PrismDiv. So the only way to move around is Tab between the nested buttons, which is not how a screen reader user will expect a tree to behave once it is announced as one.
  2. The generic <code> sits between role="tree" and role="group", which breaks the owned-element relationship the tree role requires.
  3. aria-expanded ends up on both the li[role="treeitem"] and the button inside it, so the expanded/collapsed state gets announced twice.

Announcing a tree that does not behave like one is often worse for AT users than a plain set of disclosure buttons. Since the rest of the PR already implements the disclosure pattern correctly (<button> + aria-expanded + accurate label), my suggestion is to drop role="tree", role="group" and role="treeitem" here and keep the parts that fix the labels and state. If you do want the full tree pattern, it is a good follow-up PR on its own — it needs roving tabindex and key handling to be worth having.

}}
tabIndex={0}
role="button"
aria-label="Select all text"

@displague displague Sep 4, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

(🤖 Claude Opus 5) This one I think is a net regression, and it is subtle.

This div already had role="button" before your change, so its accessible name was being computed from its contents — which is the server URL. Adding aria-label replaces that name, so a screen reader user now hears "Select all text, button" and never hears the URL itself. The visible text is still there, but for role="button" the name wins.

Since the whole point of this wrapper is to expose the server URL, I would either leave the name coming from content, or keep the label as a description instead:

<div ... role="button" title="Select all text">

or an aria-describedby pointing at visually-hidden helper text. The onKeyDown addition alongside it is a good catch though — role="button" on a div genuinely does need it.

onClick={this.clear}
aria-label="Clear search"
role="button"
tabIndex={0}

@displague displague Sep 4, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

(🤖 Claude Opus 5) Making this focusable is the right instinct, but it creates an odd tab order. ClearIcon is styled.i with position: absolute; right: ..., and it is rendered before SearchIcon and SearchInput in the DOM. So with tabIndex={0}, keyboard users tab onto the clear button before reaching the input — visually jumping from the right edge back to the field.

Two birds with one stone: make it an actual <button type="button"> and render it after SearchInput. That gives you correct tab order, Enter/Space activation for free (so the manual onKeyDown and role="button" can both go away), and real focus styling. The absolute positioning keeps it visually where it is today.

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.

fix(a11y): improve keyboard navigation and screen reader support in JSON viewer, search box, and select elements

2 participants