Repository navigation
[lexical-react][lexical-playground] Bug Fix: Announce typeahead menu options and result count to screen readers - #8929
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.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Ready for a look when you have time — green on the full matrix. There is one open question near the top about the live region that I would value your feedback on. |
|
I don't think that the lack of AriaLiveRegionExtension is a real concern since #8908 is bringing it to every editor using RichTextExtension by default. The tight coupling of the table should be addressed one way or another, would have to take a close look at exactly what the state of everything is during that situation but maybe we could annotate the menu tree with setDOMUnmanaged and make sure that isDOMUnmanaged is checked in the right place, or the event handler could check that the focus is in the right place before handling those keystrokes. |
|
Agreed on the live region — #8908 covers it. On the table, I went looking at both suggestions and may be missing something, so here is what I found.
A focus check ran into something different: when a typeahead menu is open, DOM focus stays in the editor — that is what What the table needs is "is a menu open right now", and the only things that answer that today are attributes the accessibility code sets — Would you rather the menu told the editor directly — a command, or some shared state the table can read — rather than the table inferring it from the DOM? |
|
I'd have to see what the solutions look like to answer that |
|
A command would not be a good solution though, commands are a terrible way to represent state because their initial state can't be observed and propagation isn't guaranteed based on the return value of other listeners of the same command at higher priorities. |
|
That would put an observable The part I am not sure about is where that extension should live. The typeahead is in |
|
It would go in |
|
Either shape would fix the table case. Where they differ is reach: a menu-specific flag only covers menus Lexical itself builds.
My view is that the active descendant is the state worth sharing: a signal in The one place it differs from "a menu is open" is an empty result list, where this PR clears the attribute because there is no option to point at. That is likely the behaviour you want: with nothing to move through, the arrow keys should go back to moving the caret, which is what the table does when the check is false. I am happy to build that as a follow-up. Leaving the |
|
Any thoughts on the shape for this one? Happy to build either the menu-specific state or the wider "something owns the caret keys" version as a follow-up, just let me know. |
|
If you want to prototype both I can take a look and choose the better option, otherwise you may have to wait a bit until someone has the time to review it thoroughly enough to make that kind of decision. |
|
A lint rule changed since this PR was opened so you may need to apply pnpm run lint:fix to this in order to pass integrity checks. There are also conflicts so this can't be considered until all of that is resolved. |
# Conflicts: # packages/lexical-react/src/shared/LexicalMenu.tsx
## Description
Upstream moved the MenuOption class out of shared/LexicalMenu.tsx into its own
entry point. This branch had added an ariaLabel property to that class, so the
property moves with it. Taking the move alone would have compiled and silently
dropped the feature: ariaLabel is what lets an option be announced by a spoken
name rather than by decorative glyphs in its title.
## Test plan
### After
```
$ npx vitest run --project unit packages/lexical-react/src/__tests__/unit/LexicalMenu.test.tsx
Test Files 1 passed (1)
Tests 35 passed (35)
```
There was a problem hiding this comment.
Claude identified a few issues with this approach:
Blocking
Custom menuRenderFn menus lose the listbox role. The PR changes the portal container from role="listbox" to role="presentation" and only puts the listbox on the <ul> inside defaultMenuRenderFn. Any custom renderer now ends up with role="option" items that have no listbox around them. On top of that, the editor root gets aria-owns="typeahead-listbox", which points at an id that doesn't exist. The repo's own examples/website-notion/src/plugins/SlashMenuPlugin.tsx works this way, and so do the menuRenderFn examples in docs/react/plugins.md. None of the playground menus use a custom renderer, so the e2e test can't catch this. Two possible fixes: only set role="presentation" and aria-owns when the default renderer is in use, or give custom renderers a way to get the listbox props.
Should fix
2. "No results" fires while results are still loading. The count announcement is debounced by 500ms and runs as soon as the menu mounts with 0 options. The playground's own mention lookup (dummyLookupService) also resolves after 500ms, so typing @A races it and can say "No results" just before the matches arrive. That will happen for any async source that takes longer than 500ms. Options: skip the announcement on the first mount with 0 options, or add a loading hint.
3. setContainerDivAttributes takes an ariaLabel it never uses. It's also threaded through useMenuAnchorRef and added to that hook's deps, so a label change re-runs positioning for nothing. The container no longer has any label.
Minor
- The unmount cleanup now captures the root element when the effect runs, where it used to read it at cleanup time. If the root changes while the menu is open, the stale element keeps its aria-owns.
- try { announce() } catch {} swallows every error, not just a missing AriaLiveRegionExtension.
- The new props aren't in the .js.flow files. Those files already lag behind, so this matches existing practice.
|
There are also conflicts to resolve. Normally I would assist in getting a PR over the finish line but last I checked you didn't provide access for maintainers to push to your fork so I can't. |
# Conflicts: # packages/lexical-playground/__tests__/e2e/Mentions.spec.mjs # packages/lexical-react/src/LexicalTypeaheadMenuPlugin.tsx # packages/lexical-react/src/__tests__/unit/LexicalMenu.test.tsx
…again, and a slow lookup says Loading Impact: a menu a project draws itself is a named list again, and the editor points at that list. While a search is still running and nothing is on screen yet, you hear "Loading". When the search finishes with nothing, you hear "No results". A custom menu never draws the built-in list, so the editor was pointing at an id that was not on the page, and the box around the menu was only decoration. The outer box is the list again. The built-in mentions menu is unchanged. The playground mentions menu says when its lookup is still running.
|
Custom menus: the outer box is the list again, and the editor owns that box. It was pointing at a list those menus never draw. The built-in menu stays one list, with the choices directly in it. Giving a custom menu that same direct list, so it can say "3 of 7", can be a follow-up. The render function is unchanged. Empty menu: "Loading" while a lookup is still running, "No results" when it finishes with nothing. This fixes the current gap, so the menu is in better shape. Arrow keys in a table are a standing issue, and there are a few ways to do it. Can we split that out into its own issue? |
lexical
@lexical/a11y
@lexical/clipboard
@lexical/code
@lexical/code-core
@lexical/code-prism
@lexical/code-shiki
@lexical/compiler
@lexical/devtools-core
@lexical/dragon
@lexical/eslint-plugin
@lexical/extension
@lexical/fast-check
@lexical/file
@lexical/hashtag
@lexical/headless
@lexical/history
@lexical/html
@lexical/internal
@lexical/link
@lexical/list
@lexical/mark
@lexical/markdown
@lexical/mdast
@lexical/offset
@lexical/overflow
@lexical/plain-text
@lexical/react
@lexical/rich-text
@lexical/selection
@lexical/table
@lexical/tailwind
@lexical/text
@lexical/utils
@lexical/y
@lexical/yjs
commit: |
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.
Feedback / open questions
Note: I moved this section up because there is a gap I do not know how to close
with the default setup.
By default when someone enables the typeahead there is no
AriaLiveRegionExtensionand this would cause "10 suggestions available" and"No results" not to work. The impact to the user would be silence — no way to
tell whether they mistyped, whether the menu is still open, or whether it is
still loading.
The rest of the menu will work and it will address some of the missing
functionality but it will not be complete without the live region.
What is the best way to address this issue when one adds the typeahead menu?
Note: when using the playground by default the live region is active and it
works, because
HistoryAnnounceExtensionis already in use and brings the liveregion in with it.
Two smaller observations:
The menu name. Giving each menu a real name — "Emojis", "Mentions" —
requires
menuAriaLabel, which is optional and falls back to "Typeahead menu".That names the widget, not what is in it. So the default today is the less
accessible option, and an app only gets a properly named menu if it thinks to
ask.
Making it required would be a breaking change, so not for this PR. Worth
considering for a future major version?
The table coupling. When a typeahead is open inside a table, arrow keys
should move down the menu rather than between cells.
@lexical/tableworks thisout by reading
aria-controlsoff the editor and checking it says exactly'typeahead-menu'. So an accessibility attribute quietly controls tablekeyboard behaviour from another package, with nothing linking them but that
string. This PR leaves the value alone.
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.
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.