SelectMenu: aria-activedescendant doesn't move while filtering, so screen readers keep announcing the first match
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 6.9k
- Forks
- 1.1k
- Avg merge
- 1d 7h
- Merged PRs (30d)
- 57
Description
Environment
- Operating System: Darwin (macOS)
- Node: v24.18.0
- Nuxt: 4.5.2
- Vue: 3.5.41
@nuxt/ui: 4.11.0reka-ui: 2.10.3 (2.10.4 is byte-identical in the files involved)- Browser: Chromium
Is this bug related to Nuxt or Vue?
Nuxt
Package
@nuxt/ui
Version
v4.11.0
Reproduction
The clearest reproduction is a failing test in your own suite, so I have opened a PR alongside this issue that adds one — it fails on v4 and passes with the fix.
By hand, with any USelectMenu that has a search input:
<script setup lang="ts">
const items = ['Alpha', 'Beta', 'Gamma']
const value = ref()
</script>
<template>
<USelectMenu v-model="value" :items="items" />
</template>
- Open the menu.
Alphais highlighted, and the search input'saria-activedescendantpoints at itsid. - Type
Gam. Gammais now the only option and is visibly highlighted — but inspect the search input:aria-activedescendantis unchanged, and the highlighted[role="option"]still carries the sameidit had when it wasAlpha.
With a screen reader, step 3 announces nothing: it keeps reading the option that was active before the filter ran, however much more you type.
Description
SelectMenu keys its options by position:
https://github.com/nuxt/ui/blob/v4/src/runtime/components/SelectMenu.vue#L742-L744
<ComboboxGroup v-for="(group, groupIndex) in filteredGroups" :key="`group-${groupIndex}`" ...>
<ReuseItemTemplate v-for="(item, index) in group" :key="`group-${groupIndex}-${index}`" :item="item" :index="index" />
</ComboboxGroup>
Typing re-sorts the list, and a positional key makes Vue reuse the same ComboboxItem instance and rewrite its text rather than move or replace it.
That matters because the item's id belongs to the instance — ComboboxItem does const id = useId(undefined, 'reka-combobox-item') and binds it to the element — and aria-activedescendant is derived from the highlighted element's id: ListboxFilter runs watchSyncEffect(() => activedescendant.value = rootContext.highlightedElement.value?.id).
So reka is behaving correctly, and I do not think this is a reka issue: a stable id per item instance is exactly what aria-activedescendant needs, and reka is simply handed the same element with new text. The watchSyncEffect sees no change, the attribute never mutates, and assistive technology is never told the active option moved. Item identity is information only SelectMenu has, since it owns the v-for.
Sighted users are unaffected, which is why this is easy to miss: data-highlighted lands on that same reused node, so the highlight looks like it tracks the filter perfectly.
We hit this with a blind member of our church, who could not use the venue picker in our app: it read out the first option and then stayed silent while he typed. He has confirmed a local patch of exactly this change fixes it for him.
Additional context
I intend to submit a PR for this — see the linked PR, which contains the fix and two regression tests.
Approach. 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:
filterGroupsends with.filter(group => group.some(...)), so groups that filter away entirely are dropped andfilteredGroupsindices no longer align withgroups— a group key built fromgroupIndexis wrong as soon as one group empties.- Identical primitives (
['A', 'A']) cannot be told apart by value, so each occurrence has to consume one of that value's original positions in turn. Otherwise a value-derived key collides and Vue warns about duplicate keys.
Measured in Chromium before opening this, typing into each menu and asserting the property that matters — whenever the active option's text changes, its id must change too:
| 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 |
✗ | ✗ |
Zero Vue duplicate-key warnings in either column, and selecting the highlighted option after filtering still yields the right value for every shape.
The same shape appears in three sibling components, which all filter through filterGroups and key by position, and whose reka counterparts bind aria-activedescendant the same way (ListboxFilter, DropdownMenuFilter):
src/runtime/components/InputMenu.vue#L803-L804src/runtime/components/Listbox.vue#L410-L411src/runtime/components/DropdownMenuContent.vue#L186-L187
I have deliberately not touched them in the PR: SelectMenu is the one I could measure against a real screen-reader user, and fixing 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, not for me — say the word and I will extend the PR either way.
Known limitation. virtualize is not fixed by this 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 (ComboboxInput: !rootContext.isVirtual.value). Happy to open a separate issue for it if you would like it tracked.
Logs
No response
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in src/runtime/components/SelectMenu.vue around the item-rendering loop at lines 742-744, then inspect the failing regression test in the linked PR. Compare filtering behavior across plain, duplicate, numeric, object, and grouped items; done means the active option's id changes with its text and the regression tests pass without duplicate-key warnings.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- nuxt, typescript
- Domain
- accessibility, frontend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Stale
- Clarity
- Clearly specified
- Newbie friendliness
- 35/100