Skip to content

[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
facebook:mainfrom
Electro-Jam-Instruments:a11y-quote-announce
Open

[lexical-rich-text][lexical] Feature: Tell screen reader users when a block quote is made, entered or left#9070
Electro-Jam wants to merge 3 commits into
facebook:mainfrom
Electro-Jam-Instruments:a11y-quote-announce

Conversation

@Electro-Jam

Copy link
Copy Markdown
Contributor

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.

… 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.
@vercel

vercel Bot commented Aug 16, 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 Sep 5, 2026 3:38pm UTC
lexical-playground Ready Ready Preview Sep 5, 2026 3:38pm 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 16, 2026
@etrepum etrepum added the extended-tests Run extended e2e tests on a PR label Aug 16, 2026
@etrepum

etrepum commented Aug 27, 2026

Copy link
Copy Markdown
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)
```
@Electro-Jam

Copy link
Copy Markdown
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.

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.

3 participants