[lexical-rich-text][lexical] Feature: Tell screen reader users when a block quote is made, entered or left - #9070
Open
Electro-Jam wants to merge 3 commits into
Open
Conversation
… block quote is made, entered or left
## Description
### Why
Screen reader users get no signal that they have made a block quote, and no
reliable signal when they leave one. Both are silent today, and the second one
strands them mid-sentence.
Typing a greater-than sign, then the space bar, consumes both keystrokes and
swaps the block type. Nothing is inserted. A sighted user sees the text indent
and a bar appear down the left; a screen reader announces nothing, so the user
reasonably concludes their document now contains a greater-than sign followed
by a space. Their picture of their own document is wrong and nothing corrects
it.
Leaving is worse, because the user is mid-sentence. Press Enter at the end of a
quote and the caret lands in a paragraph that is created by that same edit. A
screen reader works out that it has crossed a boundary by comparing where the
caret was against where it is. If the block on the far side is still being made
at the moment it checks, it can miss the crossing. Testing with NVDA 2026.1.1
on Windows 11, the exit was not always reported on a quote of several lines.
When it is missed, the user keeps typing believing they are still quoting, and
only finds out later.
Backspacing back into a quote is silent as well. Press Backspace at the start
of the paragraph below a quote and that paragraph is deleted, with the same
edit moving the caret up into the quote.
`@lexical/a11y` already announces undo, redo and editable/read-only. This adds
the same for quotes.
### What
Announces quotes through the editor's aria live region:
- a block becoming a quote → **"Block quote"**
- a quote ceasing to be one → **"Block quote removed"**
- a quote going away while the caret lands in another one →
**"Block quote removed, in block quote"**
- the caret entering an existing quote → **"Block quote"**
- the caret leaving a quote that is still there → **"Exiting block quote"**
`QuoteAnnounceExtension` lives in `@lexical/rich-text` beside `QuoteNode`, and
`RichTextExtension` depends on it, so quotes are announced without anyone
opting in.
### Shape
`defineExtension` with `namedSignals` and `safeCast`, a config of message
strings plus a runtime `disabled` signal, gated from an `effect` so a disabled
announcer registers no listener at all.
Only one thing is announced per commit. The mutation listener records what
happened to the quotes themselves and says nothing; the update listener runs
afterwards, when the caret has settled, and is the only one that announces.
That ordering is what lets a removal and the caret's destination be reported
together instead of one overwriting the other in the live region.
Both listeners read only the editor states they are handed. A read on the
editor would flush pending work in the middle of the commit.
The update listener returns immediately when `dirtyElements` and `dirtyLeaves`
are both empty. That is not only to keep it cheap on the commits that happen
most — every arrow key and every click — but because it states the rule
exactly: if nothing was made or unmade, nothing can have appeared or
disappeared beside the caret, so there is nothing to announce.
### What this does not announce
Moving across the edge of a quote with the arrow keys. Both blocks are already
there and the screen reader reports the boundary itself, so there is nothing to
add. The rule throughout is to speak only when the block on the far side
appears or disappears in the same update, which is exactly when the screen
reader can miss it.
Typing inside a quote is silent. So is nesting one quote inside another, which
announces nothing beyond the "Block quote" for the quote itself.
### One change outside the new files
`LexicalEditorListener.test.ts` built a rich text editor, registered an update
listener, unregistered it, and asserted the editor then had zero update
listeners. That assertion also assumed a rich text editor keeps no update
listeners of its own, which stops being true here: the announcer keeps one for
the life of the editor in order to see the caret move.
The test now records the count before registering and compares against it
afterwards, which is what the test is for — a value returned from a listener
must not be mistaken for an unregister callback — without depending on nothing
else being registered.
## Test plan
Ten cases in
`packages/lexical-rich-text/src/__tests__/unit/QuoteAnnounceExtension.test.ts`,
including the silence cases — nothing otherwise prevents an announcement on
every keystroke, so that regression would be invisible.
### Before
With `QuoteAnnounceExtension` removed from `RichTextExtension.dependencies`:
```
$ npx vitest run --project unit packages/lexical-rich-text/src/__tests__/unit/QuoteAnnounceExtension.test.ts
× announces a block becoming a quote 175ms
× announces a quote being removed 16ms
× announces leaving when the block after the quote is new 10ms
× announces entering when the block the caret came from goes away 10ms
× says nothing when disabled 10ms
Test Files 1 failed (1)
Tests 5 failed | 4 passed (9)
```
### After
```
$ npx vitest run --project unit packages/lexical-rich-text/src/__tests__/unit/QuoteAnnounceExtension.test.ts
Test Files 1 passed (1)
Tests 10 passed (10)
```
The whole unit suite:
```
$ npx vitest run --project unit
Test Files 250 passed (252)
Tests 4220 passed | 1 skipped (4223)
```
`FastPathCrossParent.test.ts` and `MdastTextRuns.test.ts` timed out at the 5s
limit during that run and pass on their own; both are fuzz tests over many
seeds and the machine was loaded.
End to end against the dev server, in both editor modes. `Markdown.spec.mjs`
imports markdown, undoes, and asserts the document came all the way back, which
is what an editor listener can break:
```
$ E2E_BROWSER=chromium E2E_EDITOR_MODE=rich-text npx playwright test \
--project=chromium packages/lexical-playground/__tests__/e2e/Markdown.spec.mjs
54 passed (58.1s)
$ E2E_BROWSER=chromium E2E_EDITOR_MODE=plain-text npx playwright test --project=chromium
711 skipped
136 passed (6.1m)
```
Not run on this machine: firefox and webkit, macOS, and anything needing a
production build.
Electro-Jam
requested review from
acywatson,
etrepum,
fantactuka,
ivailop7,
potatowagon and
zurfyx
as code owners
August 16, 2026 16:37
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Collaborator
|
A lint rule changed since this PR was opened so you'll need to apply pnpm run lint:fix to this in order to pass integrity checks |
…jects
## Description
`@lexical/internal/no-pure-annotation` replaced the rule that used to require
these. The compiler injects `/* @__PURE__ */` for every module-scope call to
`defineExtension` and `safeCast`, so writing them by hand is now an error.
## Test plan
### Before
```
$ npx eslint packages/lexical-rich-text/src/QuoteAnnounceExtension.ts
63:39 error Remove this /* @__PURE__ */ annotation ... (@lexical/compiler)
65:11 error Remove this /* @__PURE__ */ annotation ... (@lexical/compiler)
2 problems (2 errors, 0 warnings)
```
### After
```
$ npx eslint packages/lexical-rich-text/src packages/lexical/src/__tests__/unit/LexicalEditorListener.test.ts
(no output)
$ npx vitest run --project unit packages/lexical-rich-text/src/__tests__/unit/QuoteAnnounceExtension.test.ts
Test Files 1 passed (1)
Tests 10 passed (10)
```
Contributor
Author
|
Lint fix applied — the pure annotations are gone now that the compiler injects them. The branch also has main merged in, so it should be back to green. Ready for a look when you have time. |
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.
Description
Why
Screen reader users get no signal that they have made a block quote, and no
reliable signal when they leave one. Both are silent today, and the second one
strands them mid-sentence.
Typing a greater-than sign, then the space bar, consumes both keystrokes and
swaps the block type. Nothing is inserted. A sighted user sees the text indent
and a bar appear down the left; a screen reader announces nothing, so the user
reasonably concludes their document now contains a greater-than sign followed
by a space. Their picture of their own document is wrong and nothing corrects
it.
Leaving is worse, because the user is mid-sentence. Press Enter at the end of a
quote and the caret lands in a paragraph that is created by that same edit. A
screen reader works out that it has crossed a boundary by comparing where the
caret was against where it is. If the block on the far side is still being made
at the moment it checks, it can miss the crossing. Testing with NVDA 2026.1.1
on Windows 11, the exit was not always reported on a quote of several lines.
When it is missed, the user keeps typing believing they are still quoting, and
only finds out later.
Backspacing back into a quote is silent as well. Press Backspace at the start
of the paragraph below a quote and that paragraph is deleted, with the same
edit moving the caret up into the quote.
@lexical/a11yalready announces undo, redo and editable/read-only. This addsthe same for quotes.
What
Announces quotes through the editor's aria live region:
"Block quote removed, in block quote"
QuoteAnnounceExtensionlives in@lexical/rich-textbesideQuoteNode, andRichTextExtensiondepends on it, so quotes are announced without anyoneopting in.
Shape
defineExtensionwithnamedSignalsandsafeCast, a config of messagestrings plus a runtime
disabledsignal, gated from aneffectso a disabledannouncer registers no listener at all.
Only one thing is announced per commit. The mutation listener records what
happened to the quotes themselves and says nothing; the update listener runs
afterwards, when the caret has settled, and is the only one that announces.
That ordering is what lets a removal and the caret's destination be reported
together instead of one overwriting the other in the live region.
Both listeners read only the editor states they are handed. A read on the
editor would flush pending work in the middle of the commit.
The update listener returns immediately when
dirtyElementsanddirtyLeavesare both empty. That is not only to keep it cheap on the commits that happen
most — every arrow key and every click — but because it states the rule
exactly: if nothing was made or unmade, nothing can have appeared or
disappeared beside the caret, so there is nothing to announce.
What this does not announce
Moving across the edge of a quote with the arrow keys. Both blocks are already
there and the screen reader reports the boundary itself, so there is nothing to
add. The rule throughout is to speak only when the block on the far side
appears or disappears in the same update, which is exactly when the screen
reader can miss it.
Typing inside a quote is silent. So is nesting one quote inside another, which
announces nothing beyond the "Block quote" for the quote itself.
One change outside the new files
LexicalEditorListener.test.tsbuilt a rich text editor, registered an updatelistener, unregistered it, and asserted the editor then had zero update
listeners. That assertion also assumed a rich text editor keeps no update
listeners of its own, which stops being true here: the announcer keeps one for
the life of the editor in order to see the caret move.
The test now records the count before registering and compares against it
afterwards, which is what the test is for — a value returned from a listener
must not be mistaken for an unregister callback — without depending on nothing
else being registered.
Test plan
Ten cases in
packages/lexical-rich-text/src/__tests__/unit/QuoteAnnounceExtension.test.ts,including the silence cases — nothing otherwise prevents an announcement on
every keystroke, so that regression would be invisible.
Before
With
QuoteAnnounceExtensionremoved fromRichTextExtension.dependencies:After
The whole unit suite:
FastPathCrossParent.test.tsandMdastTextRuns.test.tstimed out at the 5slimit during that run and pass on their own; both are fuzz tests over many
seeds and the machine was loaded.
End to end against the dev server, in both editor modes.
Markdown.spec.mjsimports markdown, undoes, and asserts the document came all the way back, which
is what an editor listener can break:
Not run on this machine: firefox and webkit, macOS, and anything needing a
production build.