Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions packages/lexical-link/flow/LexicalLink.js.flow
Original file line number Diff line number Diff line change
Expand Up @@ -163,3 +163,17 @@ declare export function registerLink(
editor: LexicalEditor,
stores: NamedSignalsOutput<LinkConfig>,
): () => void;

export type AutoLinkAnnounceExtensionConfig = {
created: string,
createdMany: string,
destroyed: string,
destroyedMany: string,
disabled: boolean,
};
declare export var AutoLinkAnnounceExtension: LexicalExtension<
AutoLinkAnnounceExtensionConfig,
'@lexical/link/AutoLinkAnnounce',
NamedSignalsOutput<AutoLinkAnnounceExtensionConfig>,
void,
>;
1 change: 1 addition & 0 deletions packages/lexical-link/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
106 changes: 106 additions & 0 deletions packages/lexical-link/src/AutoLinkAnnounceExtension.ts
Original file line number Diff line number Diff line change
@@ -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<AutoLinkAnnounceExtensionConfig>({
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},
),
);
},
});
3 changes: 2 additions & 1 deletion packages/lexical-link/src/LexicalAutoLinkExtension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
TextNode,
} from 'lexical';

import {AutoLinkAnnounceExtension} from './AutoLinkAnnounceExtension';
import {LinkExtension} from './LexicalLinkExtension';
import {
$createAutoLinkNode,
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ElementNode>()!.getFirstChild<ElementNode>()!;
}

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<ElementNode>()) {
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('');
});
});
Loading