From 4bd462c4db6bd103d198669c2f4d0e864e3953e4 Mon Sep 17 00:00:00 2001 From: Brett Humphrey <114360916+Electro-Jam@users.noreply.github.com> Date: Fri, 21 Aug 2026 05:04:12 +0000 Subject: [PATCH] [lexical-link] Feature: Tell screen reader users when typing turns text into a link (#9071) --- .../lexical-link/flow/LexicalLink.js.flow | 14 + packages/lexical-link/package.json | 1 + .../src/AutoLinkAnnounceExtension.ts | 106 ++++++++ .../src/LexicalAutoLinkExtension.ts | 3 +- .../unit/AutoLinkAnnounceExtension.test.ts | 239 ++++++++++++++++++ packages/lexical-link/src/index.ts | 4 + pnpm-lock.yaml | 3 + 7 files changed, 369 insertions(+), 1 deletion(-) create mode 100644 packages/lexical-link/src/AutoLinkAnnounceExtension.ts create mode 100644 packages/lexical-link/src/__tests__/unit/AutoLinkAnnounceExtension.test.ts diff --git a/packages/lexical-link/flow/LexicalLink.js.flow b/packages/lexical-link/flow/LexicalLink.js.flow index 33d6ad9a9ad..87e7102e2c3 100644 --- a/packages/lexical-link/flow/LexicalLink.js.flow +++ b/packages/lexical-link/flow/LexicalLink.js.flow @@ -163,3 +163,17 @@ declare export function registerLink( editor: LexicalEditor, stores: NamedSignalsOutput, ): () => void; + +export type AutoLinkAnnounceExtensionConfig = { + created: string, + createdMany: string, + destroyed: string, + destroyedMany: string, + disabled: boolean, +}; +declare export var AutoLinkAnnounceExtension: LexicalExtension< + AutoLinkAnnounceExtensionConfig, + '@lexical/link/AutoLinkAnnounce', + NamedSignalsOutput, + void, +>; diff --git a/packages/lexical-link/package.json b/packages/lexical-link/package.json index c579e364deb..99e8af3ba5e 100644 --- a/packages/lexical-link/package.json +++ b/packages/lexical-link/package.json @@ -12,6 +12,7 @@ "main": "./dist/LexicalLink.js", "types": "./dist/typescript-too-old.d.ts", "dependencies": { + "@lexical/a11y": "workspace:*", "@lexical/extension": "workspace:*", "@lexical/html": "workspace:*", "@lexical/internal": "workspace:*", diff --git a/packages/lexical-link/src/AutoLinkAnnounceExtension.ts b/packages/lexical-link/src/AutoLinkAnnounceExtension.ts new file mode 100644 index 00000000000..aaad09f6b80 --- /dev/null +++ b/packages/lexical-link/src/AutoLinkAnnounceExtension.ts @@ -0,0 +1,106 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import {AriaLiveRegionExtension} from '@lexical/a11y'; +import {effect, namedSignals} from '@lexical/extension'; +import {defineExtension, safeCast} from 'lexical'; + +import {AutoLinkNode} from './LexicalLinkNode'; + +export interface AutoLinkAnnounceExtensionConfig { + /** Announced when typing turns text into a link. */ + created: string; + /** + * Announced when several links appear at once, as pasting a block of text + * can. `%s` is replaced with how many. + */ + createdMany: string; + /** Announced when an automatic link stops being a link. */ + destroyed: string; + /** + * Announced when several links go at once, as deleting a selection that + * spans them does. `%s` is replaced with how many. + */ + destroyedMany: string; + /** + * When `true`, automatic links are not announced. Toggle at runtime via the + * output signal. Default `false`. + */ + disabled: boolean; +} + +/** + * Announces automatic links through the {@link AriaLiveRegionExtension} sink. + * + * Typing a web address turns it into a link on its own, partway through typing + * it, with no keystroke to confirm and nothing inserted. A screen reader says + * nothing, so the user has no way to know a link now exists. + * + * A creation paired with a destruction in the same update is not announced. + * Every keystroke that extends an address rebuilds the link — the old node + * destroyed and a new one created at once — so announcing every creation would + * say "Link" at `www.example.c`, again at `o`, and again at `m`. Only a + * creation on its own is a link that was not there a moment ago. + */ +export const AutoLinkAnnounceExtension = /* @__PURE__ */ defineExtension({ + build: (_editor, config) => namedSignals(config), + config: /* @__PURE__ */ safeCast({ + created: 'Link', + createdMany: '%s links', + destroyed: 'Link removed', + destroyedMany: '%s links removed', + disabled: false, + }), + dependencies: [AriaLiveRegionExtension], + name: '@lexical/link/AutoLinkAnnounce', + register(editor, _config, state) { + const {created, createdMany, destroyed, destroyedMany, disabled} = + state.getOutput(); + const {announce} = state.getDependency(AriaLiveRegionExtension).output; + + // Gate on `disabled` from an effect so a disabled announcer registers no + // listener at all. Peek the message signals at announce time so editing + // them does not re-register. + return effect(() => + disabled.value + ? undefined + : editor.registerMutationListener( + AutoLinkNode, + nodes => { + let createdCount = 0; + let destroyedCount = 0; + for (const [, mutation] of nodes) { + if (mutation === 'created') { + createdCount++; + } else if (mutation === 'destroyed') { + destroyedCount++; + } + } + // Deleting a selection takes every link in it at once, so say + // how many rather than reporting a dozen as one. + if (createdCount > 0 && destroyedCount === 0) { + announce( + createdCount === 1 + ? created.peek() + : createdMany.peek().replace('%s', String(createdCount)), + ); + } else if (destroyedCount > 0 && createdCount === 0) { + announce( + destroyedCount === 1 + ? destroyed.peek() + : destroyedMany + .peek() + .replace('%s', String(destroyedCount)), + ); + } + }, + {skipInitialization: true}, + ), + ); + }, +}); diff --git a/packages/lexical-link/src/LexicalAutoLinkExtension.ts b/packages/lexical-link/src/LexicalAutoLinkExtension.ts index 926facb65f6..eb417fc0429 100644 --- a/packages/lexical-link/src/LexicalAutoLinkExtension.ts +++ b/packages/lexical-link/src/LexicalAutoLinkExtension.ts @@ -24,6 +24,7 @@ import { TextNode, } from 'lexical'; +import {AutoLinkAnnounceExtension} from './AutoLinkAnnounceExtension'; import {LinkExtension} from './LexicalLinkExtension'; import { $createAutoLinkNode, @@ -694,7 +695,7 @@ export function registerAutoLink( */ export const AutoLinkExtension = /* @__PURE__ */ defineExtension({ config: defaultConfig, - dependencies: [LinkExtension], + dependencies: [AutoLinkAnnounceExtension, LinkExtension], mergeConfig(config, overrides) { const merged = shallowMergeConfig(config, overrides); for (const k of ['matchers', 'changeHandlers', 'excludeParents'] as const) { diff --git a/packages/lexical-link/src/__tests__/unit/AutoLinkAnnounceExtension.test.ts b/packages/lexical-link/src/__tests__/unit/AutoLinkAnnounceExtension.test.ts new file mode 100644 index 00000000000..00c65a28e3b --- /dev/null +++ b/packages/lexical-link/src/__tests__/unit/AutoLinkAnnounceExtension.test.ts @@ -0,0 +1,239 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import {AriaLiveRegionExtension} from '@lexical/a11y'; +import { + buildEditorFromExtensions, + defineExtension, + getExtensionDependencyFromEditor, + type LexicalEditorWithDispose, +} from '@lexical/extension'; +import { + $createAutoLinkNode, + AutoLinkAnnounceExtension, + AutoLinkExtension, + autoLinkUrlMatcher, +} from '@lexical/link'; +import {RichTextExtension} from '@lexical/rich-text'; +import { + $createParagraphNode, + $createTextNode, + $getRoot, + configExtension, + type ElementNode, +} from 'lexical'; +import {afterEach, describe, expect, onTestFinished, test} from 'vitest'; + +afterEach(() => { + document.body.replaceChildren(); +}); + +function mountRoot(editor: LexicalEditorWithDispose): void { + const root = document.createElement('div'); + root.contentEditable = 'true'; + document.body.appendChild(root); + editor.setRootElement(root); + onTestFinished(() => root.remove()); +} + +function readLiveRegion(): string { + // A repeat announcement gets a trailing zero-width space so the DOM registers + // a change; strip it so assertions read naturally. + return ( + document.body.querySelector('[aria-live]')!.textContent ?? '' + ).replace(/\u200B/g, ''); +} + +function clearLiveRegion(): void { + const region = document.body.querySelector('[aria-live]'); + if (region) { + region.textContent = ''; + } +} + +/** + * Without matchers the transform unmakes the node it was just given, which is + * a different scenario from typing an address. + */ +const withMatchers = /* @__PURE__ */ configExtension(AutoLinkExtension, { + matchers: [autoLinkUrlMatcher], +}); + +function buildEditor(): LexicalEditorWithDispose { + const editor = buildEditorFromExtensions( + defineExtension({ + dependencies: [RichTextExtension, withMatchers], + name: '[root]', + }), + ); + mountRoot(editor); + return editor; +} + +/** Put a paragraph holding one automatic link into the document. */ +function addAutoLink( + editor: LexicalEditorWithDispose, + url = 'https://example.com', +): void { + editor.update( + () => { + const paragraph = $createParagraphNode(); + const link = $createAutoLinkNode(url); + link.append($createTextNode(url)); + paragraph.append(link); + $getRoot().append(paragraph); + }, + {discrete: true}, + ); +} + +function $onlyLink(): ElementNode { + return $getRoot().getLastChild()!.getFirstChild()!; +} + +describe('AutoLinkAnnounceExtension', () => { + test('announces a typed address becoming a link', () => { + using editor = buildEditor(); + addAutoLink(editor); + + expect(readLiveRegion()).toBe('Link'); + }); + + test('announces a link being removed', () => { + using editor = buildEditor(); + addAutoLink(editor); + clearLiveRegion(); + + editor.update(() => void $onlyLink().remove(), {discrete: true}); + + expect(readLiveRegion()).toBe('Link removed'); + }); + + test('says how many links went when a selection takes several', () => { + using editor = buildEditor(); + addAutoLink(editor, 'https://a.example'); + addAutoLink(editor, 'https://b.example'); + addAutoLink(editor, 'https://c.example'); + clearLiveRegion(); + + editor.update( + () => { + for (const paragraph of $getRoot().getChildren()) { + paragraph.remove(); + } + }, + {discrete: true}, + ); + + expect(readLiveRegion()).toBe('3 links removed'); + }); + + test('says how many links arrived when several appear at once', () => { + using editor = buildEditor(); + clearLiveRegion(); + + // Two addresses on their own lines, arriving together as a paste would + // bring them. Side by side in one paragraph they would run together into a + // single address, which is a different thing entirely. + editor.update( + () => { + for (const url of ['https://a.example', 'https://b.example']) { + const paragraph = $createParagraphNode(); + const link = $createAutoLinkNode(url); + link.append($createTextNode(url)); + paragraph.append(link); + $getRoot().append(paragraph); + } + }, + {discrete: true}, + ); + + expect(readLiveRegion()).toBe('2 links'); + }); + + test('stays silent when a link is rebuilt in place', () => { + using editor = buildEditor(); + addAutoLink(editor); + clearLiveRegion(); + + // What every keystroke does while an address is still being typed: the old + // node is destroyed and a new one created in the same update. The link was + // already there, so there is nothing to report. + editor.update( + () => { + const replacement = $createAutoLinkNode('https://example.com/deeper'); + replacement.append($createTextNode('https://example.com/deeper')); + $onlyLink().replace(replacement); + }, + {discrete: true}, + ); + + expect(readLiveRegion()).toBe(''); + }); + + test('stays silent while typing inside a link', () => { + using editor = buildEditor(); + addAutoLink(editor); + clearLiveRegion(); + + editor.update(() => void $onlyLink().append($createTextNode('/more')), { + discrete: true, + }); + + expect(readLiveRegion()).toBe(''); + }); + + test('honours a configured message', () => { + using editor = buildEditorFromExtensions( + defineExtension({ + dependencies: [ + RichTextExtension, + withMatchers, + configExtension(AutoLinkAnnounceExtension, {created: 'Linked'}), + ], + name: '[root]', + }), + ); + mountRoot(editor); + + addAutoLink(editor); + expect(readLiveRegion()).toBe('Linked'); + }); + + test('says nothing when disabled', () => { + using editor = buildEditor(); + const {disabled} = getExtensionDependencyFromEditor( + editor, + AutoLinkAnnounceExtension, + ).output; + + disabled.value = true; + addAutoLink(editor); + expect(readLiveRegion()).toBe(''); + + disabled.value = false; + addAutoLink(editor, 'https://second.example'); + expect(readLiveRegion()).toBe('Link'); + }); + + test('leaves an editor without automatic links alone', () => { + using editor = buildEditorFromExtensions( + defineExtension({ + dependencies: [AriaLiveRegionExtension, RichTextExtension], + name: '[root]', + }), + ); + mountRoot(editor); + + editor.update(() => void $getRoot().append($createParagraphNode()), { + discrete: true, + }); + + expect(readLiveRegion()).toBe(''); + }); +}); diff --git a/packages/lexical-link/src/index.ts b/packages/lexical-link/src/index.ts index ea2daf1348e..6d2f18cea8c 100644 --- a/packages/lexical-link/src/index.ts +++ b/packages/lexical-link/src/index.ts @@ -6,6 +6,10 @@ * */ +export { + AutoLinkAnnounceExtension, + type AutoLinkAnnounceExtensionConfig, +} from './AutoLinkAnnounceExtension'; export { type ClickableLinkConfig, ClickableLinkExtension, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 631b00f3d64..428389b8fcf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -946,6 +946,9 @@ importers: packages/lexical-link: dependencies: + '@lexical/a11y': + specifier: workspace:* + version: link:../lexical-a11y '@lexical/extension': specifier: workspace:* version: link:../lexical-extension