Skip to content

[lexical-react][lexical-playground] Bug Fix: Announce typeahead menu options and result count to screen readers - #8929

Open
Electro-Jam wants to merge 1 commit into
facebook:mainfrom
Electro-Jam-Instruments:a11y-typeahead-listbox
Open

[lexical-react][lexical-playground] Bug Fix: Announce typeahead menu options and result count to screen readers#8929
Electro-Jam wants to merge 1 commit into
facebook:mainfrom
Electro-Jam-Instruments:a11y-typeahead-listbox

Conversation

@Electro-Jam

Copy link
Copy Markdown

Why

Someone using a screen reader types :sm and a list of emoji suggestions opens. They should hear what the list is called, then the highlighted suggestion and where it sits — "Emojis, list, grinning, 1 of 10" — and one new suggestion each time they arrow. Nothing should be named that they could not have typed.

They should also hear how many matches there are, and hear that number change as they keep typing, even when the highlighted entry has not moved. And when nothing matches at all, they should be told so.

A sighted user gets all of that for free. They watch the list shrink as they type, and they watch it empty out and disappear. None of it reaches a screen reader on its own.

What was heard instead was "Typeahead menu, list, list, list item, 1 of 10". The menu announced itself as a list twice over, and then numbered an item without naming it — you are told you are on the first of ten, and not told what the first of ten is. The position was there all along. The thing it was counting was not.

None of this is specific to emoji. Every typeahead menu in the editor is built from one shared component, so the same thing happens in all of them. In the playground that is:

  • the emoji picker, typing :
  • the block menu, typing /
  • the mentions menu, typing @
  • the embed prompt, after pasting a video or post address

All four are fixed and named here.


What changed, and what it gives you

The listbox now holds the options directly.

This is what the screen reader was handed before:

listbox  "Typeahead menu"
  list                        <- this layer is what breaks the announcement
    option  "grinning"
    option  "smiley"

The positioning container carried role="listbox", but the options were not inside it. They sat one level down, inside a <ul>, and a <ul> is a list whether or not you ask for one. That middle line is the second list the user heard, and it is why the suggestions came through as list items: an option is only understood as a choice when the listbox itself holds it.

The <ul> is now the listbox, because it is the element that actually holds the options:

listbox  "Emojis"
  option  "grinning"
  option  "smiley"

Outcome: one list instead of two, and the suggestions are announced as choices rather than as anonymous entries in a list.

The editor owns the list.

The menu is rendered into <body> rather than inside the editor, so that an ancestor with overflow: hidden cannot clip it. That leaves the options outside the editor's own DOM. aria-activedescendant — how the editor says which option is current — expects to point at something the editor contains, and it no longer does, so the pointer dangles and the screen reader has nothing to follow.

aria-owns states the relationship the DOM stopped showing.

Outcome: the editor's pointer to the current option resolves, so the screen reader follows it as you arrow.

The current option is restated, not only written when it moves.

The mentions menu asks a server for its results, so it opens empty and fills a moment later. The code that tells the screen reader which option is current only ran when the highlight moved. Opening empty and then filling is not a move — the highlight sits on the first option before and after — so that code never ran, and the editor went on pointing at nothing.

The user heard the menu open, then heard nothing at all about what was in it.

The pointer is now written from whatever is highlighted right now, rather than only when it changes.

Outcome: a menu that fetches its results announces the highlighted option as soon as they land.

Options can carry their own spoken name.

An emoji option shows the glyph followed by its shortcode: 🙂 slightly_smiling_face. A screen reader already pronounces the glyph from its own emoji dictionary — "slightly smiling face" — and then reads the text beside it as well. So the name is announced twice, the second time as a run of underscored words.

The obvious repair is to name the option after the emoji's proper description instead. That reads far better, and it is wrong. Only the shortcode and the tags are searched. A user told the option is "face savouring food" has been handed a name that finds nothing when they type it — the thing that actually matches is "yum".

So MenuOption gains an ariaLabel: what an option should be called, separate from what it shows.

Outcome: an emoji is announced once, by the word you would type to find it.

The menus are named.

Without a name, every menu announces as "list". The emoji picker, the block menu and the mentions menu are indistinguishable by ear — the user has to work out which one they opened from what happens to be in it.

One of the two menu components could not be named at all. The embed prompt is built on the other one, which had no such option, so it always said "Typeahead menu" no matter what it was offering. That prop is added here, so both components can be named.

Outcome: the user hears "Emojis", "Blocks", "Mentions" or "Embed" — the name of the thing they are choosing from, rather than the name of the widget.

The number of matches is spoken aloud.

Nothing in ARIA announces a count, and the moment it matters most is the one with nothing to describe: when the query matches nothing, there is no list left to talk about, so correct markup is silence.

The count goes through the editor's existing aria live region. It is keyed on the count alone, so arrowing through options never talks over the option itself, and debounced, so a burst of typing produces one message once the user stops rather than a queue of counts from keystrokes ago.

Outcome: "10 suggestions available", or "No results". The count describes what is on offer, not how many matched — a menu that caps its list at five reports five, because five is all the user can reach. An editor with no live region is unaffected and simply does not speak the count.


What was ruled out, and why

Marking the <ul> presentational. role="presentation" asks the browser to drop an element's role, which on the <ul> should have collapsed the tree to the right shape. It has no effect here. That <ul> is the scrolling element, scrollable elements can take keyboard focus, and the browser will not drop the role of anything focusable. Nothing warns you — the attribute is accepted and ignored. Hence moving the listbox role onto it instead.

Stating the position on each option. aria-posinset and aria-setsize look like the safe belt-and-braces choice. Once the listbox holds its options, the browser derives the position from that relationship and passes it on, so the attributes only restate what it already knows. They were tried and removed; the position is announced without them.

Writing the current option only when it changes. A menu that loads its options over the network opens with none. While the list is empty the highlighted index is clamped to -1, and when the options arrive it lands back on 0 — so it never "changes", the write never happens, and the editor points at nothing. Hence writing it from the current highlight instead.

Pointing aria-controls at the listbox. The listbox is arguably what the editor controls, so repointing the attribute at it looks like a tidy-up. It breaks arrow keys inside a table. @lexical/table decides whether a typeahead is open by comparing that attribute to the literal string 'typeahead-menu', so the moment it says anything else the table stops recognising the menu and moves between cells instead of down the list. aria-controls is left exactly as it was, and aria-owns carries the ownership that aria-activedescendant needs.

Making the editor a combobox. Pointing at an option this way is usually done by declaring the field a combobox. It is not true here: the editor is a rich text field that briefly offers suggestions, and a user told they are in a combobox will expect a control that is not there. It also has to be undone when the menu closes, and changing the role of the focused element makes a screen reader treat it as something new and read it from the beginning, as though focus had jumped. ARIA 1.2 allows aria-activedescendant on a plain text box, so none of it is needed.

Setting aria-selected on every option. Marking the rest aria-selected="false" is legal, but some screen readers then say "not selected" after every arrow press. Only the highlighted option carries it.

Leaving the emoji in the option's name. Screen readers pronounce the glyph from their own dictionary, so an option named "🙂 slightly_smiling_face" is announced twice. Naming it by the emoji's description instead reads better — "face savouring food" — but only the shortcode and tags are searched, so the user would be hearing a name they cannot type.


What this does not announce

  • How many matched, only how many are offered. A menu that caps its list at five says "5 suggestions available" even when forty matched.
  • That the list contents changed while the count stayed the same. Narrowing from one set of five to a different five says nothing new.
  • Anything on selecting an option. The user already heard it when they arrowed to it.
  • Option ids remain positional (typeahead-item-0). When the top match changes the id does not — screen readers tested re-announce on the content change, so this is left alone.

Tests

17 new unit tests in LexicalMenu.test.tsx, covering:

  • structure — the listbox holds its options, exactly one list, the listbox is named
  • each optionaria-selected only on the highlighted one, ariaLabel overriding visible text
  • what the editor points at — ownership, options arriving late, never a combobox, cleanup on close
  • the count — announced, singular form, "No results", and silent while arrowing

13 of the 17 fail against the unmodified component. That is what shows they would catch a regression rather than passing regardless. The other four are guard-rails: they assert behaviour that is already correct and would catch it being broken later.

One existing e2e test was modified. Mentions.spec.mjs → "Sets correct attributes on typeahead menu container" asserted that the positioning container carries role="listbox" and the name. That arrangement is the bug, so the assertions move to the listbox, and check that every option is the listbox's own child.

Full e2e suite: 816 passed, 29 skipped, no failures. Browser tests: 112 passed. Verified by ear with NVDA on Edge across all four menus.


Feedback / open questions

Should an unnamed menu be possible? Every menu in the playground now has a real name, but menuAriaLabel is still optional and falls back to "Typeahead menu" — a name that describes the widget rather than its contents. Making it required would turn an unnamed menu into a compile error, at the cost of breaking every existing app with a typeahead until it adds a name. Left optional here; happy either way.

The table coupling above. Nothing links an ARIA attribute in @lexical/react to keyboard behaviour in @lexical/table except a string comparison. This PR works within it rather than changing it, but you may want it recorded somewhere more durable than a comment.

…options and result count to screen readers

A typeahead menu announced itself as a list twice over and numbered its
options without naming them, and said nothing as the results narrowed
or when nothing matched at all. The listbox role moves onto the element
that actually holds the options, options can carry their own spoken
name, every menu is named, and the number of matches is announced
through the editor's existing aria live region - "10 suggestions
available", or "No results" when the query matches nothing.
@vercel

vercel Bot commented Aug 5, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
lexical Ready Ready Preview Aug 5, 2026 5:53am
lexical-playground Ready Ready Preview Aug 5, 2026 5:53am

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Aug 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant