Skip to content

fix(frontend): expose RSelect as a combobox with an active descendant - #5318

Merged
sdornan merged 7 commits into
masterfrom
fix/rselect-combobox-a11y
Oct 25, 2026
Merged

sdornan merged 7 commits into
masterfrom
fix/rselect-combobox-a11y

Conversation

@sdornan

@sdornan sdornan commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Description
Explain the changes or enhancements you are proposing with this pull request.

RSelect is a custom listbox (a <button> activator plus a teleported role="listbox" panel). It had no aria-activedescendant, so a screen reader announced nothing while arrowing through options, and it lacked the keys a native select has. This brings it in line with the ARIA Authoring Practices select-only combobox pattern. It stays custom: chips, the "All" row, search and the clear/remove buttons can't be done with a native <select>.

RSelect

  • The activator is now role="combobox", with aria-controls pointing at the listbox only while it is mounted.
  • Options get stable ids, and the activator carries aria-activedescendant for the active option. When searchable, focus lives in the search input, so the input carries the combobox semantics and aria-activedescendant instead, through RTextField's existing popup prop (extended with an optional activedescendant).
  • New keys on the activator (non-searchable): Home and End jump to the first and last enabled option, PageUp and PageDown move ten rows and stop at the ends, and typing selects by title prefix (repeating one letter cycles through matches). Typing on a closed field opens it. Disabled rows are skipped.
  • The synthetic "All" row of a multi-select was mouse-only (the active index reached it through hover alone). Arrow keys, Home and the Page keys now include it, and Enter toggles it.
  • A combobox takes its name from a label, not its contents. An aria-label attribute passed to RSelect now names the activator instead of the wrapper <div>, an unlabelled field falls back to its placeholder, and an inline label is referenced through aria-labelledby.
  • Five selects that had no label at all now have one, from existing locale keys (no new strings): the language selector, the permission group, the folder-mapping type cell, and the manual and walkthrough pickers. Without this the e2e axe check reports button-name on the login, logs and library-management pages.

Side effect fixed: useSpatialNav
useSpatialNav treats every combobox as owning the arrow keys, which would have left a closed RSelect trapping focus for pad and keyboard users. A button-based combobox now owns the arrows only while open (the existing aria-haspopup + aria-expanded check), with a test that fails without the change.

Tests

  • Unit tests for the roles and ids, active-descendant tracking, Home/End/PageUp/PageDown, type-to-select and cycling, typing on a closed field, the searchable variant, the All row, and aria-label placement.
  • Play stories, which also run through axe: RSelect keyboard navigation, searchable keyboard, and multi-select All row; plus a keyboard-selection story for RDateField, which had no play().
  • PlatformSelect.stories.ts looked the select up as a button; it now looks for a combobox.

Not changed: no visual change, so no screenshots. aria-haspopup="listbox" is kept, which the e2e role spec and useSpatialNav rely on.

AI disclosure: written with Claude Code (AI-assisted), including the code, the tests and the checks below. A human has not yet reviewed the diff, and I did not test with an actual screen reader (NVDA, VoiceOver); verification is axe plus the DOM-level assertions above.

Checklist
Please check all that apply.

  • I've tested the changes locally
  • I've updated relevant comments
  • I've assigned reviewers for this PR
  • I've added unit tests that cover the changes

Verified locally on top of current master (including #5315): vue-tsc --build, ESLint on the changed files, the app vitest project, and the full Storybook project including axe (62 files, 488 tests). CI, including e2e, was green before the merge from master.

Screenshots (if applicable)

None: no visual change.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JKqnUCfoaS7bWkMLmGJ5qq

sdornan and others added 2 commits October 24, 2026 02:29
The activator is now role=combobox with aria-controls to the listbox it
opens and aria-activedescendant on the active option (on the search
input when searchable), so screen readers announce the option under
keyboard focus. Adds Home, End, PageUp, PageDown and type-to-select,
and falls back to the placeholder for the accessible name since a
combobox takes its name from a label, not its contents.

useSpatialNav no longer treats a closed button-based combobox as owning
the arrow keys, so pad and keyboard focus still leave a closed select.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JKqnUCfoaS7bWkMLmGJ5qq
The play test read the Size header button synchronously and raced the
first render, failing intermittently. Same change as in #5315.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JKqnUCfoaS7bWkMLmGJ5qq

sdornan commented Oct 24, 2026

Copy link
Copy Markdown
Contributor Author

The test job failed on RTable.stories.ts › Sort and row activation (play), which this PR does not touch. The story read the Size header button synchronously at the start of play() and races the first render, so it fails intermittently on master too. The fix (findByRole instead of getByRole) is already in #5315; I ported that one line here so this PR does not wait on it, and it becomes a no-op once #5315 merges. It passed 5 of 5 local runs.


Generated by Claude Code

sdornan and others added 2 commits October 24, 2026 02:43
…y tests

The synthetic All row could only be activated with the mouse: the
active index reached -1 through hover alone. Arrow keys, Home and Page
keys now include it, and Enter toggles it.

Adds play tests for the searchable and multi-select keyboard paths and
for RDateField, which had none.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JKqnUCfoaS7bWkMLmGJ5qq
A combobox takes its name from a label, not its contents, so selects
that relied on their visible value now fail axe (button-name). An
aria-label attribute passed to RSelect now names the activator instead
of the wrapper div, and the five selects without any label get one from
existing locale keys: the language selector, the permission group, the
folder mapping type cell, and the manual and walkthrough pickers.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JKqnUCfoaS7bWkMLmGJ5qq

sdornan commented Oct 24, 2026

Copy link
Copy Markdown
Contributor Author

The e2e job failed on this PR's earlier heads with 3 axe button-name violations (library-management, logs, login). That was this PR's doing: a combobox takes its name from a label, not its contents, so selects that were previously named by their visible value lost their name. Fixed in the next push:

  • An aria-label passed to RSelect used to land on the wrapper <div>; it now names the activator (this covers the two EventLog filters on the logs page).
  • The five selects with no label at all now have one from existing locale keys: language selector (login), folder mapping type cell (library-management), permission group, and the manual and walkthrough pickers.

No new locale keys. The full app (3,236 tests) and Storybook (488 tests, axe included) projects pass locally; I could not run Playwright e2e locally without the backend, so the e2e re-run on the new head is the check.


Generated by Claude Code

@sdornan
sdornan marked this pull request as ready for review October 25, 2026 10:04
Copilot AI balanced review requested due to automatic review settings October 25, 2026 10:04
@greptile-apps

greptile-apps Bot commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 5/5

[Medium impact] The PR appears safe to merge; no new actionable issue was found.

Summary

RSelect now exposes combobox roles and active-option references, adds keyboard navigation, and names previously unlabelled selectors.

  • RSelect exposes its active option as people move through choices.
  • Select fields use their labels to name the combobox.
  • Closed selects pass arrow keys on while open lists keep them.
  • The date-field story walks through keyboard date selection.

Reviews (2) · Last reviewed commit: "fix(frontend): keep RSelect typeahead fr..." · Reviewed by Greptile

Comment thread frontend/src/lib/forms/RSelect/RSelect.vue Outdated
Comment thread frontend/src/lib/forms/RSelect/RSelect.vue Outdated
Comment thread frontend/src/lib/forms/RSelect/RSelect.vue
Comment thread frontend/src/lib/forms/RSelect/RSelect.test.ts Outdated
Comment thread frontend/src/components/shared/LanguageSelector.vue

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Keyboard navigation still has paging, typeahead, and shortcut conflicts, and searchable selects can lose active-option announcements when focus stays on the activator.

4 open issues
What changed in this PR

This PR improves accessibility and keyboard navigation in RomM’s shared RSelect component by exposing combobox semantics and active-option references.

Changes:

  • Adds active-descendant bindings, accessible labels, paging, typeahead, and keyboard access to the “All” row.
  • Adjusts spatial navigation so closed button-based comboboxes do not trap arrow-key focus.
  • Expands unit and Storybook interaction coverage and fixes a table-story rendering race.
File Description
frontend/​src/​lib/​forms/​RTextField/​RTextField.vue Supports active-descendant references through the popup prop.
frontend/​src/​lib/​forms/​RSelect/​RSelect.vue Adds combobox semantics, labels, and keyboard navigation.
frontend/​src/​lib/​forms/​RSelect/​RSelect.test.ts Tests accessibility attributes and keyboard behavior.
frontend/​src/​lib/​forms/​RSelect/​RSelect.stories.ts Adds keyboard, search, and multi-select interaction stories.
frontend/​src/​lib/​forms/​RDateField/​RDateField.stories.ts Adds keyboard date-selection coverage.
frontend/​src/​lib/​data/​RTable/​RTable.stories.ts Waits for the sort button before interacting.
frontend/​src/​composables/​useSpatialNav/​index.ts Allows navigation away from closed button comboboxes.
frontend/​src/​composables/​useSpatialNav/​index.test.ts Covers closed-combobox focus navigation.
frontend/​src/​components/​shared/​PlatformSelect.stories.ts Queries the select by its new combobox role.
frontend/​src/​components/​shared/​LanguageSelector.vue Adds an accessible language label.
frontend/​src/​components/​Settings/​FolderMappingTypeCell.vue Labels the mapping-type select.
frontend/​src/​components/​Settings/​CreateUserDialog.vue Labels the permission-group select.
frontend/​src/​components/​GameDetails/​WalkthroughSubtab.vue Labels the walkthrough select.
frontend/​src/​components/​GameDetails/​ManualSubtab.vue Labels the manual select.

Review effort: Balanced

Comment thread frontend/src/lib/forms/RSelect/RSelect.vue Outdated
Comment thread frontend/src/lib/forms/RSelect/RSelect.vue
Comment thread frontend/src/lib/forms/RSelect/RSelect.vue
Comment thread frontend/src/lib/forms/RSelect/RSelect.vue Outdated
sdornan and others added 2 commits October 25, 2026 10:14
Paging backs off a disabled last or first row instead of stopping, the
type-to-select buffer resets when the menu closes, and the activator
keeps aria-activedescendant when a searchable select does not move
focus to its search input (touch-primary devices).

Interaction coverage now lives in the Storybook play stories, with a
new story for disabled rows. The unit tests keep only the cases the
stories do not cover.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JKqnUCfoaS7bWkMLmGJ5qq
A space inside a typing burst now continues the query instead of
picking the active row, and useGlobalHotkeys ignores keys a widget has
already claimed, so typing g then p in a select no longer navigates
away.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JKqnUCfoaS7bWkMLmGJ5qq
@sdornan
sdornan merged commit 062bd6a into master Oct 25, 2026
15 checks passed
@sdornan
sdornan deleted the fix/rselect-combobox-a11y branch October 25, 2026 18:41
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