Skip to content

Comments

fix(a11y): improve facet selector accessibility#1536

Open
Dayifour wants to merge 7 commits intonpmx-dev:mainfrom
Dayifour:feat/facet-selector-a11y
Open

fix(a11y): improve facet selector accessibility#1536
Dayifour wants to merge 7 commits 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 18, 2026 9:16am
2 Skipped Deployments
Project Deployment Actions Updated (UTC)
docs.npmx.dev Ignored Ignored Preview Feb 18, 2026 9:16am
npmx-lunaria Ignored Ignored Feb 18, 2026 9:16am

Request Review

@codecov
Copy link

codecov bot commented Feb 17, 2026

Codecov Report

❌ Patch coverage is 20.58824% with 27 lines in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
app/components/Compare/FacetSelector.vue 20.58% 17 Missing and 10 partials ⚠️

📢 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

Reworks the Compare FacetSelector component to use radio semantics for category-level controls and checkbox semantics for individual facets, adding getCategoryActiveControl(category) to determine the active category control and handleCategoryControlKeydown(category, event) to support Arrow key navigation and activation. Updates ARIA attributes and roles (role="radiogroup"/role="radio"/role="checkbox", aria-checked, aria-disabled, tabindex), adds data attributes for radiogroups and facet groups, and adjusts tests to assert aria-checked/aria-disabled instead of aria-pressed/disabled. Removes per-category i18n keys for select_category and deselect_category from locale and schema files.

Possibly related PRs

Suggested labels

a11y

Suggested reviewers

  • knowler
  • ghostdevv
  • danielroe
🚥 Pre-merge checks | ✅ 1
✅ Passed checks (1 passed)
Check name Status Explanation
Description check ✅ Passed The PR description clearly relates to the changeset, detailing accessibility improvements to the facet selector component including ARIA role changes and attribute updates.

✏️ 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

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.

@Dayifour Dayifour force-pushed the feat/facet-selector-a11y branch from 42e49c5 to bd677a3 Compare February 18, 2026 08:42
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.

Actionable comments posted: 4

Caution

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

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

93-117: ⚠️ Potential issue | 🟠 Major

aria-disabled must not mirror aria-checked on radio buttons.

Lines 96 and 109 set :aria-disabled to the same boolean as :aria-checked. When a radio is checked (aria-checked="true"), it becomes simultaneously aria-disabled="true", which semantically means the option is unavailable — not that it is the currently selected option. Screen readers announce such a combination as "checked, dimmed" (e.g., VoiceOver says "radio button label dimmed radio button unchecked" for a disabled radio), misleading users into thinking the control is broken or inaccessible rather than simply being the active selection.

Per the WAI-ARIA APG, if a radio button is checked the element has aria-checked set to true; if it is not checked, it has aria-checked set to false. aria-disabled is a separate property reserved for options that are genuinely unavailable, not for indicating the currently selected state.

The click handler and arrow-key navigation already make re-activation of the checked radio idempotent, so there is no functional need for aria-disabled here.

♻️ Proposed fix — remove `aria-disabled` from the radio buttons
  <ButtonBase
    role="radio"
    :aria-checked="isCategoryAllSelected(category)"
-   :aria-disabled="isCategoryAllSelected(category)"
    :tabindex="getCategoryActiveControl(category) === 'all' ? 0 : -1"
    ...
  >
  ...
  <ButtonBase
    role="radio"
    :aria-checked="isCategoryNoneSelected(category)"
-   :aria-disabled="isCategoryNoneSelected(category)"
    :tabindex="getCategoryActiveControl(category) === 'none' ? 0 : -1"
    ...
  >

Note: If ButtonBase uses aria-disabled internally to drive its disabled visual style, use a separate CSS class or prop to convey the "already selected" appearance, rather than relying on aria-disabled.

@github-actions
Copy link

github-actions bot commented Feb 18, 2026

Lunaria Status Overview

🌕 This pull request will trigger status changes.

Learn more

By default, every PR changing files present in the Lunaria configuration's files property will be considered and trigger status changes accordingly.

You can change this by adding one of the keywords present in the ignoreKeywords property in your Lunaria configuration file in the PR's title (ignoring all files) or by including a tracker directive in the merged commit's description.

Tracked Files

File Note
lunaria/files/ar-EG.json Localization changed, will be marked as complete.
lunaria/files/bg-BG.json Localization changed, will be marked as complete.
lunaria/files/bn-IN.json Localization changed, will be marked as complete.
lunaria/files/cs-CZ.json Localization changed, will be marked as complete.
lunaria/files/de-DE.json Localization changed, will be marked as complete.
lunaria/files/en-GB.json Localization changed, will be marked as complete.
lunaria/files/en-US.json Source changed, localizations will be marked as outdated.
lunaria/files/es-419.json Localization changed, will be marked as complete.
lunaria/files/es-ES.json Localization changed, will be marked as complete.
lunaria/files/fr-FR.json Localization changed, will be marked as complete.
lunaria/files/hi-IN.json Localization changed, will be marked as complete.
lunaria/files/id-ID.json Localization changed, will be marked as complete.
lunaria/files/it-IT.json Localization changed, will be marked as complete.
lunaria/files/ja-JP.json Localization changed, will be marked as complete.
lunaria/files/nb-NO.json Localization changed, will be marked as complete.
lunaria/files/ne-NP.json Localization changed, will be marked as complete.
lunaria/files/pl-PL.json Localization changed, will be marked as complete.
lunaria/files/pt-BR.json Localization changed, will be marked as complete.
lunaria/files/te-IN.json Localization changed, will be marked as complete.
lunaria/files/zh-CN.json Localization changed, will be marked as complete.
lunaria/files/zh-TW.json Localization changed, will be marked as complete.
Warnings reference
Icon Description
🔄️ The source for this localization has been updated since the creation of this pull request, make sure all changes in the source have been applied.

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.

Actionable comments posted: 1

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

235-238: Scope radio queries to the target category container to avoid implicit ordering assumptions.

All four tests use .findAll('button[role="radio"]').find(b => b.text() === 'all'/'none') across the entire component, then assert against 'performance'. This silently couples the tests to CATEGORY_ORDER[0] being 'performance'; a reorder or insertion would make the tests pass against the wrong category.

Prefer scoping to the specific category's radiogroup, e.g.:

♻️ Suggested approach
- const allButton = component.findAll('button[role="radio"]').find(b => b.text() === 'all')
+ // Scope to the performance category radiogroup
+ const performanceGroup = component.find('[data-facet-category-radiogroup]')
+ const allButton = performanceGroup.findAll('button[role="radio"]').find(b => b.text() === 'all')
  await allButton!.trigger('click')
  expect(mockSelectCategory).toHaveBeenCalledWith('performance')

Apply the same scoping for the 'none', "disables all", and "disables none" tests.

Also applies to: 248-252, 265-267, 277-279

Comment on lines 97 to 101
:aria-disabled="isCategoryAllSelected(category)"
:tabindex="getCategoryActiveControl(category) === 'all' ? 0 : -1"
data-radio-type="all"
@keydown="handleCategoryControlKeydown(category, $event)"
@click="selectCategory(category)"
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

aria-disabled does not suppress click events — guard the handlers explicitly.

aria-disabled is a semantic-only attribute; the browser still dispatches click events on aria-disabled="true" elements. When 'all' is already fully selected, clicking the 'all' radio still invokes selectCategory(category). The same applies to 'none'. The facet checkboxes already follow the correct pattern with @click="!facet.comingSoon && toggleFacet(facet.id)".

🛡️ Proposed fix
- `@click`="selectCategory(category)"
+ `@click`="!isCategoryAllSelected(category) && selectCategory(category)"
- `@click`="deselectCategory(category)"
+ `@click`="!isCategoryNoneSelected(category) && deselectCategory(category)"

Also applies to: 110-114

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.

🧹 Nitpick comments (4)
app/components/Compare/FacetSelector.vue (2)

26-30: getCategoryActiveControl returns 'all' for partial selection — intentional but worth a brief comment.

When some (but not all) facets in a category are selected, both isCategoryAllSelected and isCategoryNoneSelected return false, and the function falls through to return 'all'. This sets tabindex="0" on the "all" radio, which follows the roving-tabindex pattern correctly. A short inline comment on the fallback would help future readers understand it's deliberate.

📝 Suggested clarification
 function getCategoryActiveControl(category: string): 'all' | 'none' {
   if (isCategoryAllSelected(category)) return 'all'
   if (isCategoryNoneSelected(category)) return 'none'
+  // Partial selection: default focus to 'all' per roving-tabindex convention
   return 'all'
 }

89-99: aria-disabled on radios was removed, but click handlers still fire unconditionally.

A previous review flagged that aria-disabled does not suppress click events. In the current code the aria-disabled attribute is gone from the radio buttons entirely, but the click handlers still call selectCategory/deselectCategory even when the action is a no-op (e.g. clicking "all" when every facet is already selected). If selectCategory is idempotent this is harmless, but guarding the handlers keeps the semantics explicit and avoids unnecessary work.

♻️ Proposed guard
          `@click`="selectCategory(category)"
+         <!-- Consider: `@click`="!isCategoryAllSelected(category) && selectCategory(category)" -->
          `@click`="deselectCategory(category)"
+         <!-- Consider: `@click`="!isCategoryNoneSelected(category) && deselectCategory(category)" -->

Also applies to: 101-111

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

232-282: Non-null assertions on .find() results will produce opaque errors on failure.

Several places (Lines 238, 252, 269, 281) use ! on the result of .find(), e.g. allButton!.trigger('click'). If the element isn't found (e.g. due to a regression), the error will be a generic TypeError: Cannot read properties of undefined rather than a clear test-failure message. Using expect(...).toBeDefined() before the assertion (as done in the checkbox test on Line 175) would improve debuggability.

📝 Example for one call site
       const allButton = component.findAll('button[role="radio"]').find(b => b.text() === 'all')
+      expect(allButton).toBeDefined()
       await allButton!.trigger('click')

257-282: Consider adding an explicit assertion to document the facet category assumption.

The test relies on downloads being in the health category (not performance). Whilst the assumption is correct per the current codebase, explicitly asserting this—or selecting a facet from a more obviously distinct category (e.g., license from security)—would make the test more resilient to future category mapping changes.

@Dayifour
Copy link
Author

Hi @knowler ! I've completed the refactor. Here is a summary of the latest changes:

A11y & Keyboard: Implemented a proper roving tabindex and arrow-key navigation for the radiogroup.

Refined Semantics: Replaced aria-pressed with aria-checked and ensured aria-disabled is only used where appropriate.

Clean Labels: Linked groups via aria-labelledby and removed redundant aria-label attributes on checkboxes.

Tests & CI: Updated all component tests to match the new ARIA roles. All 17 checks are now green, including i18n schema and TypeScript.

I've also addressed the latest feedback regarding decorative separators and event guards

@Dayifour Dayifour requested a review from knowler February 18, 2026 09:23
@knowler
Copy link
Member

knowler commented Feb 18, 2026

@Dayifour I’ll review this in-depth again once myself and the team come back from “vacation” on Saturday.

Upon seeing the changes though, I’m having doubts about whether radio controls the correct tool for the job here as when interacting with the “all” or “none” control, there’s no way to clear that input other than using the individual facet controls and that feels a bit strange to me, especially when using a screen reader. I do agree the existing production implementation does need change as setting disabled to effectively do the same thing in a worse way than a radio control is also not great.

I might open an issue where a proper discussion can take place with regard to the design of these controls.

@Dayifour
Copy link
Author

Thanks for the feedback, @knowler! I completely understand the concern. By implementing the strict WAI-ARIA radio pattern, it indeed highlights that the 'All/None' actions behave more like command buttons than persistent states, which can be tricky for screen reader users to mentally map.
I'm totally open to exploring a different pattern if the team decides to move away from the radio approach.

Enjoy your vacation, and I'll look forward to the team's feedback on Saturday! No rush at all

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