fix(richtext-lexical): allow exiting the RTE with the keyboard in Firefox (#8654)
Closes #8653. Originally this PR was for making the `IndentFeature` opt-in instead of opt-out, which would have been a breaking change. After some discussion it was determined it would be better if we could keep the `IndentFeature` by default and instead come up with a custom escape key solution to prevent keyboard users from becoming trapped in the editor. These changes are my interpretation of how we can solve this problem in a way that feels natural for a keyboard user. When a keyboard user becomes trapped, the usual approach is to press the escape key (e.g. modals) to be able to leave the current context and continue navigating. These changes allow that to happen while minimising the cognitive load by not needing to remember whether the `IndentFeature` is toggled on or off. I've also ensured the `IndentFeature` can actually be turned off if consciously removed from the lexical editor features (previously it was still enabled even if it was removed). Ideally this should be handled on the lexical side in the `TabIndentationPlugin` itself (I will begin to look into the feasibility of this), but for now this should be suitable to ensure the experience for keyboard users isn't completely blocked (there are a number of other improvements that could be made but I will create more specific issues for those). Open to discussion and amendments. Once we're aligned on the approach I'm happy to implement tests as needed. ### Before https://github.com/user-attachments/assets/95183bb6-f36e-4b44-8c3b-d880c822d315 ### After https://github.com/user-attachments/assets/d34be50a-8f31-4b81-83d1-236d5ce9d8b5 --------- Co-authored-by: Germán Jabloñski <43938777+GermanJablo@users.noreply.github.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import type { ToolbarGroup } from '../../toolbars/types.js'
|
||||
import { IndentDecreaseIcon } from '../../../lexical/ui/icons/IndentDecrease/index.js'
|
||||
import { IndentIncreaseIcon } from '../../../lexical/ui/icons/IndentIncrease/index.js'
|
||||
import { createClientFeature } from '../../../utilities/createClientFeature.js'
|
||||
import { IndentPlugin } from './plugins/index.js'
|
||||
import { toolbarIndentGroupWithItems } from './toolbarIndentGroup.js'
|
||||
|
||||
const toolbarGroups: ToolbarGroup[] = [
|
||||
@@ -55,6 +56,12 @@ const toolbarGroups: ToolbarGroup[] = [
|
||||
]
|
||||
|
||||
export const IndentFeatureClient = createClientFeature({
|
||||
plugins: [
|
||||
{
|
||||
Component: IndentPlugin,
|
||||
position: 'normal',
|
||||
},
|
||||
],
|
||||
toolbarFixed: {
|
||||
groups: toolbarGroups,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
'use client'
|
||||
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext.js'
|
||||
import { TabIndentationPlugin } from '@lexical/react/LexicalTabIndentationPlugin'
|
||||
import { mergeRegister } from '@lexical/utils'
|
||||
import { COMMAND_PRIORITY_NORMAL, FOCUS_COMMAND, KEY_ESCAPE_COMMAND } from 'lexical'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import type { PluginComponent } from '../../../typesClient.js'
|
||||
|
||||
export const IndentPlugin: PluginComponent<undefined> = () => {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
const [firefoxFlag, setFirefoxFlag] = useState<boolean>(false)
|
||||
|
||||
useEffect(() => {
|
||||
return mergeRegister(
|
||||
editor.registerCommand<MouseEvent>(
|
||||
FOCUS_COMMAND,
|
||||
() => {
|
||||
setFirefoxFlag(false)
|
||||
return true
|
||||
},
|
||||
COMMAND_PRIORITY_NORMAL,
|
||||
),
|
||||
editor.registerCommand(
|
||||
KEY_ESCAPE_COMMAND,
|
||||
() => {
|
||||
setFirefoxFlag(true)
|
||||
editor.getRootElement()?.blur()
|
||||
return true
|
||||
},
|
||||
COMMAND_PRIORITY_NORMAL,
|
||||
),
|
||||
)
|
||||
}, [editor, setFirefoxFlag])
|
||||
|
||||
useEffect(() => {
|
||||
if (!firefoxFlag) {
|
||||
return
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!['Escape', 'Shift'].includes(e.key)) {
|
||||
setFirefoxFlag(false)
|
||||
}
|
||||
// Pressing Shift+Tab after blurring refocuses the editor in Firefox
|
||||
// we focus parent to allow exiting the editor
|
||||
if (e.shiftKey && e.key === 'Tab') {
|
||||
editor.getRootElement()?.parentElement?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [editor, firefoxFlag])
|
||||
|
||||
return <TabIndentationPlugin />
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary.js'
|
||||
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin.js'
|
||||
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin.js'
|
||||
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin.js'
|
||||
import { TabIndentationPlugin } from '@lexical/react/LexicalTabIndentationPlugin.js'
|
||||
import { BLUR_COMMAND, COMMAND_PRIORITY_LOW, FOCUS_COMMAND } from 'lexical'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
@@ -115,7 +114,7 @@ export const LexicalEditor: React.FC<
|
||||
<RichTextPlugin
|
||||
contentEditable={
|
||||
<div className="editor-scroller">
|
||||
<div className="editor" ref={onRef}>
|
||||
<div className="editor" ref={onRef} tabIndex={-1}>
|
||||
<LexicalContentEditable />
|
||||
</div>
|
||||
</div>
|
||||
@@ -173,8 +172,6 @@ export const LexicalEditor: React.FC<
|
||||
{editorConfig?.features?.markdownTransformers?.length > 0 && <MarkdownShortcutPlugin />}
|
||||
</React.Fragment>
|
||||
)}
|
||||
|
||||
<TabIndentationPlugin />
|
||||
{editorConfig.features.plugins?.map((plugin) => {
|
||||
if (plugin.position === 'normal') {
|
||||
return (
|
||||
|
||||
@@ -784,6 +784,54 @@ describe('lexicalMain', () => {
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* When the escape key is pressed, Firefox resets the active element to the beginning of the page instead of staying with the editor.
|
||||
* By applying a keydown listener when the escape key is pressed, we can programatically focus the previous element if shift+tab is pressed.
|
||||
*/
|
||||
test('ensure escape key can be used to move focus away from editor', async () => {
|
||||
await navigateToLexicalFields()
|
||||
|
||||
const richTextField = page.locator('.rich-text-lexical').first()
|
||||
await richTextField.scrollIntoViewIfNeeded()
|
||||
await expect(richTextField).toBeVisible()
|
||||
// Wait until there at least 10 blocks visible in that richtext field - thus wait for it to be fully loaded
|
||||
await expect(page.locator('.rich-text-lexical').nth(2).locator('.lexical-block')).toHaveCount(
|
||||
10,
|
||||
)
|
||||
await expect(page.locator('.shimmer-effect')).toHaveCount(0)
|
||||
|
||||
const paragraph = richTextField.locator('.LexicalEditorTheme__paragraph').first()
|
||||
await paragraph.scrollIntoViewIfNeeded()
|
||||
await expect(paragraph).toBeVisible()
|
||||
|
||||
const textField = page.locator('#field-title')
|
||||
const addBlockButton = page.locator('.add-block-menu').first()
|
||||
|
||||
// Pressing 'Escape' allows focus to be moved to the previous element
|
||||
await paragraph.click()
|
||||
await page.keyboard.press('Tab')
|
||||
await page.keyboard.press('Escape')
|
||||
await page.keyboard.press('Shift+Tab')
|
||||
await expect(textField).toBeFocused()
|
||||
|
||||
// Pressing 'Escape' allows focus to be moved to the next element
|
||||
await paragraph.click()
|
||||
await page.keyboard.press('Tab')
|
||||
await page.keyboard.press('Escape')
|
||||
await page.keyboard.press('Tab')
|
||||
await expect(addBlockButton).toBeFocused()
|
||||
|
||||
// Focus is not moved to the previous element if 'Escape' is not pressed
|
||||
await paragraph.click()
|
||||
await page.keyboard.press('Shift+Tab')
|
||||
await expect(textField).not.toBeFocused()
|
||||
|
||||
// Focus is not moved to the next element if 'Escape' is not pressed
|
||||
await paragraph.click()
|
||||
await page.keyboard.press('Tab')
|
||||
await expect(addBlockButton).not.toBeFocused()
|
||||
})
|
||||
|
||||
test('creating a link, then clicking in the link drawer, then saving the link, should preserve cursor position and not move cursor to beginning of richtext field', async () => {
|
||||
await navigateToLexicalFields()
|
||||
const richTextField = page.locator('.rich-text-lexical').first()
|
||||
@@ -798,7 +846,6 @@ describe('lexicalMain', () => {
|
||||
const paragraph = richTextField.locator('.LexicalEditorTheme__paragraph').first()
|
||||
await paragraph.scrollIntoViewIfNeeded()
|
||||
await expect(paragraph).toBeVisible()
|
||||
|
||||
/**
|
||||
* Type some text
|
||||
*/
|
||||
@@ -1129,7 +1176,7 @@ describe('lexicalMain', () => {
|
||||
const lexicalField: SerializedEditorState = lexicalDoc.lexicalRootEditor
|
||||
|
||||
// @ts-expect-error no need to type this
|
||||
await expect(lexicalField?.root?.children[1].fields.someTextRequired).toEqual('test')
|
||||
expect(lexicalField?.root?.children[1].fields.someTextRequired).toEqual('test')
|
||||
}).toPass({
|
||||
timeout: POLL_TOPASS_TIMEOUT,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user