[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
Conversation
…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.
Electro-Jam
requested review from
acywatson,
etrepum,
fantactuka,
ivailop7,
potatowagon and
zurfyx
as code owners
August 5, 2026 05:52
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Someone using a screen reader types
:smand 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:
:/@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:
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: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 withoverflow: hiddencannot 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-ownsstates 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
MenuOptiongains anariaLabel: 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-posinsetandaria-setsizelook 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-controlsat 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/tabledecides 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-controlsis left exactly as it was, andaria-ownscarries the ownership thataria-activedescendantneeds.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-activedescendanton a plain text box, so none of it is needed.Setting
aria-selectedon every option. Marking the restaria-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
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:aria-selectedonly on the highlighted one,ariaLabeloverriding visible text13 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 carriesrole="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
menuAriaLabelis 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/reactto keyboard behaviour in@lexical/tableexcept a string comparison. This PR works within it rather than changing it, but you may want it recorded somewhere more durable than a comment.