Skip to content

fix(a11y): improve facet selector accessibility#1536

Open
Dayifour wants to merge 1 commit intonpmx-dev:mainfrom
Dayifour:feat/facet-selector-a11y
Open

fix(a11y): improve facet selector accessibility#1536
Dayifour wants to merge 1 commit intonpmx-dev:mainfrom
Dayifour:feat/facet-selector-a11y

Conversation

@Dayifour
Copy link

summary

This PR improves the accessibility of the compare facet selector.

changes

  • treat the “all / none” controls as a radiogroup with role="radiogroup" and role="radio"
  • expose selection state with aria-checked instead of aria-pressed
  • treat individual facets as checkboxes with role="checkbox" and aria-checked
  • add accessible group labelling per category
  • update FacetSelector tests to reflect the new ARIA roles and attributes

testing

  • pnpm test:nuxt -- --runTestsByPath test/nuxt/components/compare/FacetSelector.spec.ts

notes

  • no visual changes, only ARIA / a11y improvements

@vercel
Copy link

vercel bot commented Feb 17, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
npmx.dev Ready Ready Preview, Comment Feb 17, 2026 10:04pm
2 Skipped Deployments
Project Deployment Actions Updated (UTC)
docs.npmx.dev Ignored Ignored Preview Feb 17, 2026 10:04pm
npmx-lunaria Ignored Ignored Feb 17, 2026 10:04pm

Request Review

@codecov
Copy link

codecov bot commented Feb 17, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ All tests successful. No failed tests found.

📢 Thoughts on this report? Let us know!

@Dayifour Dayifour changed the title fix(compare): improve facet selector accessibility fix(a11y): improve facet selector accessibility Feb 17, 2026
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 17, 2026

📝 Walkthrough

Walkthrough

This pull request enhances the accessibility and semantic structure of the FacetSelector component. Category controls have been converted from plain buttons to radio controls with appropriate ARIA attributes (aria-checked). Facet groups now use the group role with ARIA labels, and individual facets have been updated to use checkbox roles with aria-checked and aria-disabled attributes. The test file has been updated to reflect these ARIA attribute changes, replacing aria-pressed queries with aria-checked queries to match the component's new semantic structure.

Suggested labels

a11y

Suggested reviewers

  • whitep4nth3r
  • danielroe
  • knowler
🚥 Pre-merge checks | ✅ 1
✅ Passed checks (1 passed)
Check name Status Explanation
Description check ✅ Passed The pull request description clearly explains the accessibility improvements being made to the facet selector component, detailing specific ARIA roles and attributes being added.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Tip

Issue Planner is now in beta. Read the docs and try it out! Share your feedback on Discord.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
app/components/Compare/FacetSelector.vue (1)

31-64: ⚠️ Potential issue | 🟠 Major

role="radiogroup" is missing required keyboard navigation, and disabled breaks focus discoverability.

Two issues that together undermine the radio group semantics:

1 — Missing arrow-key handling.
User interactions for radiogroups must replicate the user interaction of a user entering into a group of same-named HTML radio buttons. Keyboard events for tabs, space, and arrow keys must be captured. Click events on both the radio elements and their associated labels must also be captured. Additionally, focus must be managed. The component only has @click handlers; there are no keydown handlers for arrow-key navigation between the All/None radios.

2 — HTML disabled on role="radio" removes options from keyboard reach.
As radio is an interactive control, it must be focusable and keyboard accessible. If the role is applied to a non-focusable element, use the tabindex attribute to change this. Using the HTML disabled attribute removes the button entirely from the focus order. There can be instances where elements need to be exposed as disabled, but are still available for users to find when navigating via the Tab key. Doing so can improve their discoverability as they will not be removed from the focus order of the web page, as aria-disabled does not change the focusability of such elements.

For role="radio" buttons, replace the HTML disabled attribute with aria-disabled and guard the click handler programmatically:

🔧 Proposed fix for both the All and None buttons
  <ButtonBase
    role="radio"
    :aria-label="$t('compare.facets.select_category', { category: getCategoryLabel(category) })"
    :aria-checked="isCategoryAllSelected(category)"
-   :disabled="isCategoryAllSelected(category)"
+   :aria-disabled="isCategoryAllSelected(category)"
    `@click`="!isCategoryAllSelected(category) && selectCategory(category)"
    size="small"
  >
    {{ $t('compare.facets.all') }}
  </ButtonBase>
  <span class="text-2xs text-fg-muted/40">/</span>
  <ButtonBase
    role="radio"
    :aria-label="$t('compare.facets.deselect_category', { category: getCategoryLabel(category) })"
    :aria-checked="isCategoryNoneSelected(category)"
-   :disabled="isCategoryNoneSelected(category)"
+   :aria-disabled="isCategoryNoneSelected(category)"
    `@click`="!isCategoryNoneSelected(category) && deselectCategory(category)"
    size="small"
  >
    {{ $t('compare.facets.none') }}
  </ButtonBase>

Arrow-key support (keydown handler on the radiogroup container or individual buttons) also needs to be wired up to satisfy the APG pattern.

test/nuxt/components/compare/FacetSelector.spec.ts (1)

167-167: ⚠️ Potential issue | 🟡 Minor

Stale test description — still references aria-pressed after switching to aria-checked.

🛠️ Suggested fix
-    it('applies aria-pressed for selected state', async () => {
+    it('applies aria-checked for selected state', async () => {
🧹 Nitpick comments (3)
app/components/Compare/FacetSelector.vue (2)

31-35: Identical aria-label on both the radiogroup and the inner group makes them indistinguishable to screen readers.

Both containers use getCategoryLabel(category) (e.g. "Performance") as their accessible name, so a screen reader user would hear the same category label announced twice when moving through the page structure.

Consider using aria-labelledby on both elements pointing to a single labelling <span> with a generated ID (e.g., category-label-${category}), and optionally differentiate the inner group's label (e.g., "Performance facets").

♻️ Suggested refactor
  <div v-for="category in categoryOrder" :key="category">
-     <div
-       class="flex items-center gap-2 mb-2"
-       role="radiogroup"
-       :aria-label="getCategoryLabel(category)"
-     >
-       <span class="text-3xs text-fg-subtle uppercase tracking-wider">
-         {{ getCategoryLabel(category) }}
-       </span>
+     <span
+       :id="`category-label-${category}`"
+       class="text-3xs text-fg-subtle uppercase tracking-wider"
+     >
+       {{ getCategoryLabel(category) }}
+     </span>
+     <div
+       class="flex items-center gap-2 mb-2"
+       role="radiogroup"
+       :aria-labelledby="`category-label-${category}`"
+     >
       <!-- All / None buttons -->
     </div>

     <div
       class="flex items-center gap-1.5 flex-wrap"
       role="group"
-      :aria-label="getCategoryLabel(category)"
+      :aria-labelledby="`category-label-${category}`"
     >

Also applies to: 67-71


82-82: Pre-existing focus-visible:outline-accent/70 on ButtonBase violates the project's global button focus convention.

The project applies button focus-visible styling globally via app/assets/main.css. This inline utility class is redundant and inconsistent with that approach.

♻️ Suggested removal
  class="gap-1 px-1.5 rounded transition-colors focus-visible:outline-accent/70"
+ class="gap-1 px-1.5 rounded transition-colors"

Based on learnings: "In the npmx.dev project, ensure that focus-visible styling for button and select elements is implemented globally in app/assets/main.css… Do not apply per-element inline utility classes like focus-visible:outline-accent/70 on these elements in Vue templates or components."

test/nuxt/components/compare/FacetSelector.spec.ts (1)

173-175: button[aria-checked] now matches All/None role="radio" buttons too, making the assertion ambiguous.

With the new implementation, both facet checkboxes and the All/None radio buttons carry aria-checked. In this test setup (downloads selected), the None radios for the health, compatibility, and security categories also have aria-checked="true". The test passes because the downloads facet button appears first in the DOM, but this is a DOM-order coincidence.

Narrow the selector to target only role="checkbox" buttons:

♻️ Suggested fix
-     const buttons = component.findAll('button[aria-checked]')
-     const selectedButton = buttons.find(b => b.attributes('aria-checked') === 'true')
+     const buttons = component.findAll('button[role="checkbox"]')
+     const selectedButton = buttons.find(b => b.attributes('aria-checked') === 'true')

@Dayifour
Copy link
Author

Dayifour commented Feb 17, 2026

Ready for review

Copy link
Member

@knowler knowler left a comment

Choose a reason for hiding this comment

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

If we’re switching to radios we’ll need to implement a roving tabindex for the controls inside of the group and control these using arrow keys, since role=radiogroup is a composite widget (i.e. it inherits from the abstract role=select which itself inherits from role=composite).

Furthermore, when operating with a keyboard, the option should become checked when focused (i.e. following focus the arrow keys), rather than on Enter—the latter usually submits a form for the native controls, so we’d likely need to prevent that.

We can get rid of the redundant :aria-label="facet.label" on the <button> elements as those would be computed from their contents which include the label even if we’re setting role=radio and role=checkbox. In the case the the facets are “coming soon” and are disabled, it’s fine that that is included in the label.


It would be nice to just use the built-in elements for this as we’re breaking the first rule of ARIA, but I don’t think it would be very easy with how our components/styling currently work.

Comment on lines +34 to 36
:aria-label="getCategoryLabel(category)"
>
<span class="text-3xs text-fg-subtle uppercase tracking-wider">
Copy link
Member

Choose a reason for hiding this comment

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

Nice catch with the labels for the group/radiogroup roles.

Let’s set an id on the <span> with the visual label and use aria-labelledby to reference it.

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.

2 participants