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 6 commits into
meta:mainfrom
Electro-Jam-Instruments:a11y-typeahead-listbox
Open

Electro-Jam wants to merge 6 commits into
meta:mainfrom
Electro-Jam-Instruments:a11y-typeahead-listbox

Conversation

@Electro-Jam

@Electro-Jam Electro-Jam commented Aug 5, 2026 •

Copy link
Copy Markdown
Contributor

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.


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
AriaLiveRegionExtension and 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 HistoryAnnounceExtension is already in use and brings the live
region 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/table works this
out by reading aria-controls off the editor and checking it says exactly
'typeahead-menu'. So an accessibility attribute quietly controls table
keyboard 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:

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.


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 option — aria-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.

…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
lexical Ready Ready Preview Nov 1, 2026 5:04pm UTC
lexical-playground Ready Ready Preview Nov 1, 2026 5:04pm UTC

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
@etrepum etrepum added the extended-tests Run extended e2e tests on a PR label Aug 5, 2026
@Electro-Jam

Copy link
Copy Markdown
Contributor Author

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.

@etrepum

etrepum commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

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.

@Electro-Jam

Copy link
Copy Markdown
Contributor Author

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.

setDOMUnmanaged seems to be read only in LexicalMutations.ts, on the MutationObserver path, where it means "do not treat this DOM as editor content when it mutates". I could not find anything reading it during key handling, so I am not sure how it would tell the table that a menu is open. The captureSelection option looks aimed at letting an embedded widget keep its own caret, which may not be this case.

A focus check ran into something different: when a typeahead menu is open, DOM focus stays in the editor — that is what aria-activedescendant is for, and why the editor still receives the keystrokes. So focus looks the same whether the menu is open or closed, unless there is somewhere else to check that I have not found.

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 — aria-controls, or aria-activedescendant pointing at a live option.

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?

@etrepum

etrepum commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

I'd have to see what the solutions look like to answer that

@etrepum

etrepum commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

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.

@Electro-Jam

Copy link
Copy Markdown
Contributor Author

@lexical/history, @lexical/code and @lexical/clipboard already share state as signals read with getPeerDependencyFromEditor, which returns undefined when the extension is absent — so nothing gains a hard dependency.

That would put an observable menuOpen (or similar) on an extension, written by the typeahead and read by the table, and the aria-controls comparison goes away.

The part I am not sure about is where that extension should live. The typeahead is in @lexical/react, and the table should not depend on React. Somewhere shared like @lexical/extension, or does it belong elsewhere?

@etrepum

etrepum commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

It would go in @lexical/extension - the real question is whether something specific to menus makes sense or if there's a more general concept to be addressed here.

@Electro-Jam

Copy link
Copy Markdown
Contributor Author

Either shape would fix the table case. Where they differ is reach: a menu-specific flag only covers menus Lexical itself builds.

ContentEditable already takes an ariaActiveDescendant prop, so an app can point the editor at its own popup list today. Inside a table that app has the same problem — the arrow keys are taken before its list sees them, and there is nothing it can set to prevent it. A menuOpen written by LexicalMenu would not reach it.

My view is that the active descendant is the state worth sharing: a signal in @lexical/extension holding the id the editor is currently pointing at, written by the typeahead and by the ariaActiveDescendant path, read by the table. It is something the editor has to track for ARIA anyway, so there is no second source of truth to keep in step, and the table's DOM comparison becomes a read of state instead of a string match on an attribute.

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 aria-controls comparison as it is here keeps this PR about the listbox.

@Electro-Jam

Copy link
Copy Markdown
Contributor Author

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.

@etrepum

etrepum commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

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.

@etrepum

etrepum commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

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)
```

@etrepum etrepum left a comment •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@etrepum

etrepum commented Sep 26, 2026

Copy link
Copy Markdown
Collaborator

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.
@Electro-Jam

Copy link
Copy Markdown
Contributor Author

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?

@pkg-pr-new

pkg-pr-new Bot commented Nov 1, 2026

Copy link
Copy Markdown
lexical

npm i https://pkg.pr.new/lexical@8929

@lexical/a11y

npm i https://pkg.pr.new/@lexical/a11y@8929

@lexical/clipboard

npm i https://pkg.pr.new/@lexical/clipboard@8929

@lexical/code

npm i https://pkg.pr.new/@lexical/code@8929

@lexical/code-core

npm i https://pkg.pr.new/@lexical/code-core@8929

@lexical/code-prism

npm i https://pkg.pr.new/@lexical/code-prism@8929

@lexical/code-shiki

npm i https://pkg.pr.new/@lexical/code-shiki@8929

@lexical/compiler

npm i https://pkg.pr.new/@lexical/compiler@8929

@lexical/devtools-core

npm i https://pkg.pr.new/@lexical/devtools-core@8929

@lexical/dragon

npm i https://pkg.pr.new/@lexical/dragon@8929

@lexical/eslint-plugin

npm i https://pkg.pr.new/@lexical/eslint-plugin@8929

@lexical/extension

npm i https://pkg.pr.new/@lexical/extension@8929

@lexical/fast-check

npm i https://pkg.pr.new/@lexical/fast-check@8929

@lexical/file

npm i https://pkg.pr.new/@lexical/file@8929

@lexical/hashtag

npm i https://pkg.pr.new/@lexical/hashtag@8929

@lexical/headless

npm i https://pkg.pr.new/@lexical/headless@8929

@lexical/history

npm i https://pkg.pr.new/@lexical/history@8929

@lexical/html

npm i https://pkg.pr.new/@lexical/html@8929

@lexical/internal

npm i https://pkg.pr.new/@lexical/internal@8929

@lexical/link

npm i https://pkg.pr.new/@lexical/link@8929

@lexical/list

npm i https://pkg.pr.new/@lexical/list@8929

@lexical/mark

npm i https://pkg.pr.new/@lexical/mark@8929

@lexical/markdown

npm i https://pkg.pr.new/@lexical/markdown@8929

@lexical/mdast

npm i https://pkg.pr.new/@lexical/mdast@8929

@lexical/offset

npm i https://pkg.pr.new/@lexical/offset@8929

@lexical/overflow

npm i https://pkg.pr.new/@lexical/overflow@8929

@lexical/plain-text

npm i https://pkg.pr.new/@lexical/plain-text@8929

@lexical/react

npm i https://pkg.pr.new/@lexical/react@8929

@lexical/rich-text

npm i https://pkg.pr.new/@lexical/rich-text@8929

@lexical/selection

npm i https://pkg.pr.new/@lexical/selection@8929

@lexical/table

npm i https://pkg.pr.new/@lexical/table@8929

@lexical/tailwind

npm i https://pkg.pr.new/@lexical/tailwind@8929

@lexical/text

npm i https://pkg.pr.new/@lexical/text@8929

@lexical/utils

npm i https://pkg.pr.new/@lexical/utils@8929

@lexical/y

npm i https://pkg.pr.new/@lexical/y@8929

@lexical/yjs

npm i https://pkg.pr.new/@lexical/yjs@8929

commit: ed70f92

This branch was successfully deployed

2 active deployments
Preview – lexical — ed70f925 Deployed Nov 1, 2026 by vercel[bot]
Preview – lexical-playground — ed70f925 Deployed Nov 1, 2026 by vercel[bot]
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. extended-tests Run extended e2e tests on a PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants