Skip to content

Make Accordion keyboard operable (WCAG 2.2 SC 2.1.1) - #118

Merged
bencounsell merged 1 commit into
mainfrom
fix/accordion-keyboard-accessibility
Aug 13, 2026
Merged

bencounsell merged 1 commit into
mainfrom
fix/accordion-keyboard-accessibility

Conversation

@bencounsell

Copy link
Copy Markdown
Member

Fixes the accordion keyboard accessibility failure reported on the forum: https://forums.realmacsoftware.com/t/bug-report-accordion-component-is-not-keyboard-operable-wcag-2-2-level-a-failure/57319

The bug

The accordion header was rendered as a <div role="button"> that was never focusable and never listened for key events — no tabindex in the template, and the Alpine summary bind registered only @click.

Both halves were missing. role="button" on a div does not synthesise Enter/Space activation the way a native <button> does, so the header could not be reached by Tab and would not have activated even if focus were forced onto it. With the panel hidden by x-show/x-collapse there was no alternative route to the content, so keyboard-only users could not read anything inside an accordion.

That's a WCAG 2.2 Success Criterion 2.1.1 (Keyboard, Level A) failure, affecting every Elements site using the component.

Corroborating that it was an oversight rather than a decision: the summary already shipped focus:ring-2 styling for an element that could never receive focus.

What changed

Keyboard operability

  • tabindex="0" on the summary, gated @if(!edit) so it doesn't interfere with the editor's focus handling. Static rather than Alpine-bound, so the header is focusable before Alpine hydrates (the shared Alpine scripts are deferred).
  • @keydown.enter.prevent and @keydown.space.prevent on the summary bind. .prevent on Space stops the page scrolling.

ARIA cleanup

  • Dropped the redundant :aria-hidden on the panel — x-show already applies display:none, and keeping both marked the panel hidden while it was still visible mid-transition.
  • Removed role="group", aria-roledescription and the aria-label that interpolated the internal node id (screen readers were announcing the raw id).

Focus ring

  • focus:focus-visible: so it only shows for keyboard users, and ring-blue-500ring-brand-500 so it follows the theme.

Design note

Kept div[role="button"] rather than moving to a native <button>. The header title is a @dropzone holding arbitrary author content, so wrapping it in a <button> risks invalid nested-interactive markup and would fight click-to-edit in the editor. div[role="button"] + tabindex + explicit key handlers is the ARIA-sanctioned equivalent and the minimal change that clears the Level A failure.

Verification

Driven in a browser harness reproducing the rendered markup with the pack's own alpine.js / alpine-collapse.js:

  • Tab reaches the header (previously impossible) and :focus-visible matches
  • Enter toggles open and closed, exactly one state change per press — no double-fire
  • Space toggles and defaultPrevented === true, confirming the scroll guard
  • Mouse click toggles once and does not show the focus ring
  • aria-expanded tracks state; aria-hidden is gone from the panel
  • Grouped accordions still close their siblings; ungrouped ones are unaffected
  • openOnLoad: true still renders open
  • Tab moves into the open panel's link — the content the report said was unreachable

For the reviewer

  • hooks.js is regenerated via npm run build:hooks; only hooks.source.js was hand-edited.
  • That build also picks up switchToBool from the current shared hooks, so the accordion's hooks.js carries that change too. The repo's other 27 generated hooks.js files are stale against the current RWElementsPacksTools (commit 8d1a2a5), and were reverted here to keep this diff focused — worth a deliberate sync separately.
  • The panel's collapse reveal animation could not be verified in the automated browser: x-collapse's height animation lands at 0 there. It reproduces on the untouched mouse @click path, all markup variants fail identically, and plain x-show without x-collapse works — so it's the plugin's transition timing in that environment, not this change. Still worth a quick look in the real app.

Not addressed here

Flagged rather than fixed, to keep the diff reviewable: the APG heading-wrapper structure (tangled up with how dropzones work), role="region" creating one landmark per panel, focus management when a grouped sibling auto-closes, and the malformed bg--50 classes (which affect 5 components, not just this one).

🤖 Generated with Claude Code

The accordion header was rendered as a <div role="button"> that was
never focusable and never listened for key events: no tabindex in the
template, and the Alpine summary bind registered only @click. Since
role="button" on a div does not synthesise Enter/Space activation the
way a native <button> does, both halves were missing — the header could
not be reached by Tab, and would not have activated even if it were.

With the panel hidden via x-show/x-collapse there was no alternative
route to the content, so keyboard-only users could not read anything
inside an accordion. Reported on the forum as a WCAG 2.2 Success
Criterion 2.1.1 (Keyboard, Level A) failure.

- Add static tabindex="0" to the summary, gated on preview mode so it
  does not interfere with the editor's focus handling. Static rather
  than Alpine-bound so the header is focusable before Alpine hydrates.
- Add @keydown.enter.prevent and @keydown.space.prevent to the summary
  bind; .prevent on Space stops the page scrolling.
- Drop the redundant :aria-hidden on the panel. x-show already applies
  display:none, and keeping both marked the panel hidden while it was
  still visible mid-transition.
- Remove role="group", aria-roledescription and the aria-label that
  interpolated the internal node id, which screen readers announced
  verbatim.
- Switch the summary focus ring to focus-visible so it only shows for
  keyboard users, and use ring-brand-500 so it follows the theme
  instead of a hardcoded blue.

Verified in a browser harness: Tab reaches the header, Enter and Space
each toggle exactly once, Space calls preventDefault, aria-expanded
tracks state, grouped accordions still close siblings, openOnLoad still
renders open, and Tab now moves into the open panel's content.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

1 participant