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:
Riley Pearce
2024-11-28 06:23:19 +10:30
committed by GitHub
parent 3961223cc1
commit 3c35d81fe5
4 changed files with 118 additions and 6 deletions

View File

@@ -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,
},

View File

@@ -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 />
}

View File

@@ -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 (

View File

@@ -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,
})