Skip to content

fix(SelectMenu): key items by identity so aria-activedescendant moves while filtering - #6911

Open
jo23sh wants to merge 1 commit into
nuxt:v4from
jo23sh:fix/select-menu-item-keys
Open

jo23sh wants to merge 1 commit into
nuxt:v4from
jo23sh:fix/select-menu-item-keys

Conversation

@jo23sh

@jo23sh jo23sh commented Sep 2, 2026

Copy link
Copy Markdown

Resolves #6910.

The problem

SelectMenu keys its options by position, so typing in the search input — which re-sorts the list through filterGroups — makes Vue reuse the same ComboboxItem instance and rewrite its text rather than move or replace it.

The item's id belongs to that instance (ComboboxItem: useId(undefined, 'reka-combobox-item')), and aria-activedescendant is derived from the highlighted element's id (ListboxFilter: watchSyncEffect(() => activedescendant.value = rootContext.highlightedElement.value?.id)). So the attribute keeps pointing at an id that never changes, the watchSyncEffect never fires, and assistive technology is never told the active option moved — it keeps announcing the option that was active before the filter ran.

Sighted users see nothing wrong, because data-highlighted lands on that same reused node.

This is not a reka bug: a stable id per item instance is exactly what aria-activedescendant needs, and reka is handed the same element with new text. Item identity is information only SelectMenu has, since it owns the v-for.

The fix

Key each rendered item by where it sits in the unfiltered items, so the key follows the item through any re-sort. Two details the obvious fix gets wrong, and both are covered:

  • filterGroups ends with .filter(group => group.some(...)), so groups that filter away entirely are dropped and filteredGroups indices no longer align with groups. A group key built from groupIndex is wrong as soon as one group empties, so the group key comes from its first item instead.
  • Identical primitives (['A', 'A']) cannot be told apart by value, so each occurrence consumes one of that value's original positions in turn. A naive value-derived key collides and Vue warns.

Tests

Two regression tests in test/components/SelectMenu.spec.ts:

  • moves aria-activedescendant when filtering changes the highlighted item — the property that matters: when the highlighted option's text changes, its id must change too, and the search input's aria-activedescendant must follow. On v4 it fails with expected 'reka-combobox-item-v-3' not to be 'reka-combobox-item-v-3' — the text went from Alpha to Gamma and the id did not move.
  • keeps keys unique when items repeat — pins the duplicate-primitive case, asserting no Vue duplicate-key warning.

It types into the search input rather than setting search-term as a prop, because it is reka's own search-term watcher that moves the highlight, and typing is the path a user takes.

Locally, on this branch: test/components/SelectMenu.spec.ts 194 passed (both the nuxt and vue projects, no snapshot churn — keys are not rendered), eslint clean on both changed files, vue-tsc --noEmit clean.

Beyond the suite, measured by hand in Chromium across the item shapes, asserting the same property:

item shape before after
plain strings
duplicate strings
numbers
objects, label only, no value-key
objects with value-key
duplicate labels, distinct values
groups + type: label/separator
virtualize

Selecting the highlighted option after filtering still yields the right value for every shape.

Scope, and what this deliberately leaves alone

  • virtualize is not fixed, and looks like a separate problem: that branch renders through ComboboxVirtualizer, which recycles its own nodes and is passed no key at all, so the same id is reused by construction. reka excludes the virtual path from its highlight handling too (!rootContext.isVirtual.value).
  • The same positional-key pattern is in InputMenu.vue#L803-L804, Listbox.vue#L410-L411 and DropdownMenuContent.vue#L186-L187, which all filter through filterGroups and whose reka counterparts bind aria-activedescendant the same way. I have left them alone on purpose: SelectMenu is the one I could measure against a real screen-reader user, and doing all four properly wants the keying extracted into useFilter (which already owns filterGroups) rather than copied four times. That is a shape question for you — happy to extend this PR either way.

Why it was reported

A blind member of our church could not use a venue picker in our app: it read out the first option and then went silent while he typed. He has been running exactly this change as a local patch and confirms it fixes it for him.

Options were keyed by position, so typing in the search input made Vue reuse
the same ComboboxItem instance and rewrite its text rather than move it. The
item id belongs to that instance, and reka reads aria-activedescendant from
the highlighted element's id - so the attribute kept pointing at an id that
never changed and a screen reader was never told the active option moved. It
kept announcing the first match however much the member typed, while the
highlight tracked the filter correctly for sighted users.

Keys each item by where it sits in the unfiltered items instead, so the key
follows the item through the re-sort. filterGroups drops groups that filter
away entirely, so the group key has to come from an item too; and identical
primitives cannot be told apart by value, so each occurrence takes one of that
value's original positions in turn and the keys stay unique.

Resolves nuxt#6910

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added the v4 #4488 label Sep 2, 2026
@coderabbitai

coderabbitai Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Team

Run ID: 9f0f1bb6-4d64-4737-b09e-46b2e6cb2cb1

📥 Commits

Reviewing files that changed from the base of the PR and between 6caa6a9 and 048385e.

📒 Files selected for processing (2)
  • src/runtime/components/SelectMenu.vue
  • test/components/SelectMenu.spec.ts

Included review availability: Your plan provides up to 8 included reviews per hour; 7 remain after this review.


📝 Walkthrough

Walkthrough

SelectMenu now derives stable group and entry keys from positions in the unfiltered groups. Duplicate primitive values consume original positions in sequence, with fallback keys when needed. Non-virtualized rendering uses these keys. Tests cover aria-activedescendant updates after filtering and duplicate-key warnings for repeated items.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk: ⚪ Minimal · up to 04838

This localized change preserves option identity during filtering so assistive technology follows the highlighted option correctly, with regression coverage for reordered and duplicate items. No actionable merge-blocking risk remains beyond normal checks and review.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: SelectMenu items now use identity-based keys so aria-activedescendant updates during filtering.
Description check ✅ Passed The description directly explains the filtering bug, the keying fix, supported item cases, regression tests, and documented virtualization limitation.
Linked Issues check ✅ Passed The implementation satisfies issue #6910 by updating SelectMenu item identity during filtering, handling filtered groups and duplicate primitive values, and adding regression tests. The virtualized pa…
Out of Scope Changes check ✅ Passed The changes are limited to SelectMenu key generation and related regression tests. They directly support issue #6910 and do not introduce unrelated code changes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 1…
Full details: Linked Issues check

Explanation

The implementation satisfies issue #6910 by updating SelectMenu item identity during filtering, handling filtered groups and duplicate primitive values, and adding regression tests. The virtualized path is explicitly identified as separate scope in the issue.

Full details: Docstring Coverage

Explanation

No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 1 files. (1 skipped: 1 unsupported.)

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

Some tools did not complete. Review the errors below.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

src/runtime/components/SelectMenu.vue

Parsing error: Unexpected token )

test/components/SelectMenu.spec.ts

Parsing error: Unexpected token {


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.

@codspeed

codspeed Bot commented Sep 2, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing jo23sh:fix/select-menu-item-keys (048385e) with v4 (6caa6a9)

Open in CodSpeed

@pkg-pr-new

pkg-pr-new Bot commented Sep 2, 2026

Copy link
Copy Markdown
npm i https://pkg.pr.new/@nuxt/ui@6911

commit: 048385e

This branch was successfully deployed

1 active deployment
Preview – ui 048385ef Deployed Sep 2, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v4 #4488

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SelectMenu: aria-activedescendant doesn't move while filtering, so screen readers keep announcing the first match

1 participant