nuxt / nuxt/ui

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

Open
#6,910 0 comments 0 reactions 0 assignees View on GitHub

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.0
  • reka-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>
  1. Open the menu. Alpha is highlighted, and the search input's aria-activedescendant points at its id.
  2. Type Gam.
  3. Gamma is now the only option and is visibly highlighted — but inspect the search input: aria-activedescendant is unchanged, and the highlighted [role="option"] still carries the same id it had when it was Alpha.

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:

  • 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.
  • 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-L804
  • src/runtime/components/Listbox.vue#L410-L411
  • src/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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.