diff --git a/packages/lexical-playground/__tests__/e2e/DraggableBlock.spec.mjs b/packages/lexical-playground/__tests__/e2e/DraggableBlock.spec.mjs index 4ad75f75131..d4a267047a1 100644 --- a/packages/lexical-playground/__tests__/e2e/DraggableBlock.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/DraggableBlock.spec.mjs @@ -6,13 +6,19 @@ * */ +import {expect} from '@playwright/test'; + import { assertHTML, dragDraggableMenuTo, + dragMouse, focusEditor, initialize, + insertYouTubeEmbed, mouseMoveToSelector, + selectorBoundingBox, test, + YOUTUBE_SAMPLE_URL, } from '../utils/index.mjs'; test.describe('DraggableBlock', () => { @@ -187,4 +193,45 @@ test.describe('DraggableBlock', () => { `, ); }); + + test('Restores focus after dragging a selected decorator block', async ({ + page, + isPlainText, + browserName, + isCollab, + }) => { + test.skip(isCollab); + test.skip(isPlainText); + + await focusEditor(page); + await page.keyboard.type('Before'); + await insertYouTubeEmbed(page, YOUTUBE_SAMPLE_URL); + await page.keyboard.type('After'); + + const decorator = page.locator('.PlaygroundEditorTheme__embedBlock'); + const decoratorElement = page.locator('div[data-lexical-decorator="true"]'); + const decoratorBox = await decoratorElement.boundingBox(); + if (decoratorBox === null) { + throw new Error('Decorator block is not visible'); + } + const pointerX = decoratorBox.x + 10; + const pointerY = decoratorBox.y + decoratorBox.height / 2; + await decorator.evaluate(element => element.click()); + await expect(decorator).toHaveClass( + /PlaygroundEditorTheme__embedBlockFocus/, + ); + await decoratorElement.dispatchEvent('mousemove', { + clientX: pointerX, + clientY: pointerY, + }); + await page.locator('.draggable-block-menu').waitFor(); + await dragMouse( + page, + await selectorBoundingBox(page, '.draggable-block-menu'), + await selectorBoundingBox(page, 'p:has-text("After")'), + {positionEnd: 'end', positionStart: 'middle', slow: true}, + ); + + await expect(page.locator('.ContentEditable__root')).toBeFocused(); + }); }); diff --git a/packages/lexical-react/src/LexicalDraggableBlockPlugin.tsx b/packages/lexical-react/src/LexicalDraggableBlockPlugin.tsx index addf3f56675..6c9618f83bb 100644 --- a/packages/lexical-react/src/LexicalDraggableBlockPlugin.tsx +++ b/packages/lexical-react/src/LexicalDraggableBlockPlugin.tsx @@ -72,6 +72,19 @@ function getTopLevelNodeKeys(editor: LexicalEditor): string[] { return editor.read('latest', () => $getRoot().getChildrenKeys()); } +function restoreEditorFocus( + editor: LexicalEditor, + rootElement: HTMLElement, +): void { + rootElement.focus({preventScroll: true}); + editor.update(() => { + const selection = $getSelection(); + if (selection !== null && !selection.dirty) { + selection.dirty = true; + } + }); +} + function getCollapsedMargins(elem: HTMLElement): { marginTop: number; marginBottom: number; @@ -477,14 +490,7 @@ function useDraggableBlockMenu( // Blur is caused by clicking on drag handle - restore focus immediately // to prevent cursor from disappearing. This must be synchronous to work. if (rootElement) { - rootElement.focus({preventScroll: true}); - // Force selection update to ensure cursor is visible - editor.update(() => { - const selection = $getSelection(); - if (selection !== null && !selection.dirty) { - selection.dirty = true; - } - }); + restoreEditorFocus(editor, rootElement); } // Prevent the event from propagating to LexicalEvents handler event.stopImmediatePropagation(); @@ -512,13 +518,7 @@ function useDraggableBlockMenu( isOnMenu(activeElement) ) { // Focus is on menu - restore to root and prevent blur command - rootElement.focus({preventScroll: true}); - editor.update(() => { - const selection = $getSelection(); - if (selection !== null && !selection.dirty) { - selection.dirty = true; - } - }); + restoreEditorFocus(editor, rootElement); return true; // Prevent command from propagating } return false; @@ -558,14 +558,7 @@ function useDraggableBlockMenu( ) { // Restore focus synchronously - don't use requestAnimationFrame as blur already happened // and we need immediate focus restoration to maintain cursor visibility - rootElement.focus({preventScroll: true}); - // Force selection update to ensure cursor is visible - editor.update(() => { - const selection = $getSelection(); - if (selection !== null && !selection.dirty) { - selection.dirty = true; - } - }); + restoreEditorFocus(editor, rootElement); } } } @@ -574,11 +567,9 @@ function useDraggableBlockMenu( isDraggingBlockRef.current = false; hideTargetLine(targetLineRef.current); - // Firefox-specific fix: Use editor.focus() to properly restore both focus and - // selection after drag ends. This ensures cursor visibility immediately. - if (IS_FIREFOX) { - // editor.focus() handles both focus restoration and selection update properly - editor.focus(); + const rootElement = editor.getRootElement(); + if (rootElement !== null && getActiveElement(rootElement) !== rootElement) { + restoreEditorFocus(editor, rootElement); } } return createPortal( diff --git a/packages/lexical-rich-text/flow/LexicalRichText.js.flow b/packages/lexical-rich-text/flow/LexicalRichText.js.flow index e85e4608522..344663c3623 100644 --- a/packages/lexical-rich-text/flow/LexicalRichText.js.flow +++ b/packages/lexical-rich-text/flow/LexicalRichText.js.flow @@ -6,6 +6,7 @@ * * @flow strict */ +import type {NamedSignalsOutput} from '@lexical/extension'; import type { DOMConversionMap, EditorConfig, @@ -101,3 +102,15 @@ export type SerializedHeadingNode = { declare export var RichTextExtension: LexicalExtension; + +export type HeadingAnnounceExtensionConfig = { + created: string, + destroyed: string, + disabled: boolean, +}; +declare export var HeadingAnnounceExtension: LexicalExtension< + HeadingAnnounceExtensionConfig, + '@lexical/rich-text/HeadingAnnounce', + NamedSignalsOutput, + void, +>; diff --git a/packages/lexical-rich-text/package.json b/packages/lexical-rich-text/package.json index b6d7124c5ec..adb790d660b 100644 --- a/packages/lexical-rich-text/package.json +++ b/packages/lexical-rich-text/package.json @@ -38,6 +38,7 @@ } }, "dependencies": { + "@lexical/a11y": "workspace:*", "@lexical/clipboard": "workspace:*", "@lexical/dragon": "workspace:*", "@lexical/extension": "workspace:*", diff --git a/packages/lexical-rich-text/src/HeadingAnnounceExtension.ts b/packages/lexical-rich-text/src/HeadingAnnounceExtension.ts new file mode 100644 index 00000000000..eb386b84e0a --- /dev/null +++ b/packages/lexical-rich-text/src/HeadingAnnounceExtension.ts @@ -0,0 +1,104 @@ +/** + * 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 {$getNodeByKey, defineExtension, type NodeKey, safeCast} from 'lexical'; + +import {$isHeadingNode, HeadingNode, type HeadingTagType} from './index'; + +export interface HeadingAnnounceExtensionConfig { + /** + * Announced when a block becomes a heading. `%s` is replaced with the + * level (1-6). + */ + created: string; + /** + * Announced when a heading stops being a heading. `%s` is replaced with the + * level it was. + */ + destroyed: string; + /** + * When `true`, headings are not announced. Toggle at runtime via the output + * signal. Default `false`. + */ + disabled: boolean; +} + +function $readHeadingTag(key: NodeKey): HeadingTagType | null { + const node = $getNodeByKey(key); + return $isHeadingNode(node) ? node.getTag() : null; +} + +/** + * Announces headings through the {@link AriaLiveRegionExtension} sink: a block + * becoming a heading, and a heading ceasing to be one. + * + * The markdown shortcut consumes both keystrokes (`#` then space) and swaps the + * block type, which is silent to a screen reader — so without this the user has + * no way to know the transformation happened, or to confirm the level without + * navigating out of the block and back in. + * + * Only those two transitions announce. Typing inside a heading, moving the + * caret through it, and deleting text while the heading survives are all + * silent; announcing on every keystroke would make a heading impossible to type + * into. + * + * A destroyed node is gone from the current editor state, so its level is read + * from the previous state `registerMutationListener` provides. + */ +export const HeadingAnnounceExtension = /* @__PURE__ */ defineExtension({ + build: (_editor, config) => namedSignals(config), + config: /* @__PURE__ */ safeCast({ + created: 'Heading level %s', + destroyed: 'Heading level %s removed', + disabled: false, + }), + dependencies: [AriaLiveRegionExtension], + name: '@lexical/rich-text/HeadingAnnounce', + register(editor, _config, state) { + const {created, destroyed, 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( + HeadingNode, + (nodes, {prevEditorState}) => { + // A level change fires both a removal and a creation. In the + // first block the removal reaches the live region, announcing the + // old level instead of the new one. Prefer the creation; only + // announce removal when nothing replaced it. + let createdTag: HeadingTagType | null = null; + let destroyedTag: HeadingTagType | null = null; + for (const [key, mutation] of nodes) { + if (mutation === 'created' && createdTag === null) { + createdTag = editor.read('latest', () => + $readHeadingTag(key), + ); + } else if (mutation === 'destroyed' && destroyedTag === null) { + destroyedTag = prevEditorState.read(() => + $readHeadingTag(key), + ); + } + } + if (createdTag !== null) { + announce(created.peek().replace('%s', createdTag.slice(1))); + } else if (destroyedTag !== null) { + announce(destroyed.peek().replace('%s', destroyedTag.slice(1))); + } + }, + {skipInitialization: true}, + ), + ); + }, +}); diff --git a/packages/lexical-rich-text/src/LexicalRichTextExtension.ts b/packages/lexical-rich-text/src/LexicalRichTextExtension.ts index 108cbf650df..a525b95ce94 100644 --- a/packages/lexical-rich-text/src/LexicalRichTextExtension.ts +++ b/packages/lexical-rich-text/src/LexicalRichTextExtension.ts @@ -22,6 +22,7 @@ import { type TextFormatType, } from 'lexical'; +import {HeadingAnnounceExtension} from './HeadingAnnounceExtension'; import { defaultShouldHandlePasteAsFiles, type EscapeFormatTriggerConfig, @@ -112,6 +113,7 @@ export const RichTextExtension = /* @__PURE__ */ defineExtension({ config: /* @__PURE__ */ safeCast(DEFAULT_RICH_TEXT_CONFIG), conflictsWith: ['@lexical/plain-text'], dependencies: [ + HeadingAnnounceExtension, DragonExtension, NormalizeInlineElementsExtension, NormalizeTripleClickSelectionExtension, diff --git a/packages/lexical-rich-text/src/__tests__/unit/HeadingAnnounceExtension.test.ts b/packages/lexical-rich-text/src/__tests__/unit/HeadingAnnounceExtension.test.ts new file mode 100644 index 00000000000..3bdc7ad533b --- /dev/null +++ b/packages/lexical-rich-text/src/__tests__/unit/HeadingAnnounceExtension.test.ts @@ -0,0 +1,254 @@ +/** + * 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 {PlainTextExtension} from '@lexical/plain-text'; +import { + $createHeadingNode, + HeadingAnnounceExtension, + type HeadingTagType, + RichTextExtension, +} from '@lexical/rich-text'; +import { + $createParagraphNode, + $createTextNode, + $getRoot, + configExtension, +} from 'lexical'; +import {afterEach, describe, expect, onTestFinished, test} from 'vitest'; + +afterEach(() => { + document.body.replaceChildren(); +}); + +// The live region follows the editor's root document, so a mounted root is +// required for it to exist. +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, ''); +} + +/** + * Append a heading, as typing the markdown shortcut on a fresh block does. + * + * Deliberately appends rather than replacing the root: clearing would destroy + * the previous heading in the same update, which is block-type conversion - a + * different scenario from the one under test. + */ +function addHeading( + editor: LexicalEditorWithDispose, + tag: HeadingTagType, + text = 'Title', +): void { + editor.update( + () => { + const heading = $createHeadingNode(tag); + heading.append($createTextNode(text)); + $getRoot().append(heading); + }, + {discrete: true}, + ); +} + +/** Remove the last block, as backspacing a heading away does. */ +function removeLastBlock(editor: LexicalEditorWithDispose): void { + editor.update(() => void $getRoot().getLastChild()?.remove(), { + discrete: true, + }); +} + +describe('HeadingAnnounceExtension', () => { + test('announces every heading level as it is created', () => { + using editor = buildEditorFromExtensions( + defineExtension({ + dependencies: [RichTextExtension], + name: '[root]', + }), + ); + mountRoot(editor); + + const levels: HeadingTagType[] = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']; + for (const tag of levels) { + addHeading(editor, tag); + expect(readLiveRegion()).toBe(`Heading level ${tag.slice(1)}`); + } + }); + + test('announces the level a heading had when it is removed', () => { + using editor = buildEditorFromExtensions( + defineExtension({ + dependencies: [RichTextExtension], + name: '[root]', + }), + ); + mountRoot(editor); + + addHeading(editor, 'h2'); + expect(readLiveRegion()).toBe('Heading level 2'); + + removeLastBlock(editor); + expect(readLiveRegion()).toBe('Heading level 2 removed'); + }); + + test('stays silent while editing inside a heading', () => { + using editor = buildEditorFromExtensions( + defineExtension({ + dependencies: [RichTextExtension], + name: '[root]', + }), + ); + mountRoot(editor); + + addHeading(editor, 'h3', 'Before'); + expect(readLiveRegion()).toBe('Heading level 3'); + + // Announcing on every keystroke would make a heading impossible to type + // into, so text changes within a surviving heading must not announce. + document.body.querySelector('[aria-live]')!.textContent = ''; + editor.update( + () => { + const heading = $getRoot().getFirstChild()!; + heading.selectEnd().insertText(' and after'); + }, + {discrete: true}, + ); + expect(readLiveRegion()).toBe(''); + }); + + test('announces the new level when a heading changes level', () => { + using editor = buildEditorFromExtensions( + defineExtension({ + dependencies: [RichTextExtension], + name: '[root]', + }), + ); + mountRoot(editor); + + // Typing '## ' straight after '# ' swaps the block's level: one node is + // destroyed and another created in the same update. The useful thing to + // hear is the level you are now in. + editor.update( + () => { + $getRoot().clear().append($createHeadingNode('h1')); + }, + {discrete: true}, + ); + expect(readLiveRegion()).toBe('Heading level 1'); + + editor.update( + () => { + $getRoot().clear().append($createHeadingNode('h2')); + }, + {discrete: true}, + ); + expect(readLiveRegion()).toBe('Heading level 2'); + }); + + test('respects message overrides from configExtension', () => { + using editor = buildEditorFromExtensions( + defineExtension({ + dependencies: [ + configExtension(HeadingAnnounceExtension, { + created: 'Now an H%s', + destroyed: 'H%s gone', + }), + RichTextExtension, + ], + name: '[root]', + }), + ); + mountRoot(editor); + + addHeading(editor, 'h4'); + expect(readLiveRegion()).toBe('Now an H4'); + + removeLastBlock(editor); + expect(readLiveRegion()).toBe('H4 gone'); + }); + + test('reflects message signal changes at runtime', () => { + using editor = buildEditorFromExtensions( + defineExtension({ + dependencies: [RichTextExtension], + name: '[root]', + }), + ); + mountRoot(editor); + + const {created} = getExtensionDependencyFromEditor( + editor, + HeadingAnnounceExtension, + ).output; + created.value = 'Section, depth %s'; + + addHeading(editor, 'h2'); + expect(readLiveRegion()).toBe('Section, depth 2'); + }); + + test('does not announce while disabled, and resumes when re-enabled', () => { + using editor = buildEditorFromExtensions( + defineExtension({ + dependencies: [RichTextExtension], + name: '[root]', + }), + ); + mountRoot(editor); + + const {disabled} = getExtensionDependencyFromEditor( + editor, + HeadingAnnounceExtension, + ).output; + + disabled.value = true; + addHeading(editor, 'h1'); + expect(readLiveRegion()).toBe(''); + + disabled.value = false; + addHeading(editor, 'h2'); + expect(readLiveRegion()).toBe('Heading level 2'); + }); + + test('leaves a plain text editor alone', () => { + // The announcer ships with rich text, so a plain text editor never gets + // it. Guards the dependency direction: @lexical/a11y must stay usable + // without @lexical/rich-text, or an editor built for plain text refuses + // to build - "extension @lexical/plain-text conflicts with + // @lexical/rich-text". + using editor = buildEditorFromExtensions( + defineExtension({ + dependencies: [AriaLiveRegionExtension, PlainTextExtension], + name: '[root]', + }), + ); + mountRoot(editor); + + // There are no headings to watch for, so there is nothing to say. + editor.update(() => void $getRoot().append($createParagraphNode()), { + discrete: true, + }); + + expect(readLiveRegion()).toBe(''); + }); +}); diff --git a/packages/lexical-rich-text/src/index.ts b/packages/lexical-rich-text/src/index.ts index 2f18b7cfb12..469934ec013 100644 --- a/packages/lexical-rich-text/src/index.ts +++ b/packages/lexical-rich-text/src/index.ts @@ -1986,6 +1986,10 @@ export function registerRichText( return removeListener; } +export { + HeadingAnnounceExtension, + type HeadingAnnounceExtensionConfig, +} from './HeadingAnnounceExtension'; export { type RichTextConfig, RichTextExtension, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b4851dce24..042a46dfccc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1394,6 +1394,9 @@ importers: packages/lexical-rich-text: dependencies: + '@lexical/a11y': + specifier: workspace:* + version: link:../lexical-a11y '@lexical/clipboard': specifier: workspace:* version: link:../lexical-clipboard