Skip to content

Commit

Permalink
fix(richtext-lexical): allow exiting the RTE with the keyboard in Fir…
Browse files Browse the repository at this point in the history
…efox (#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>
  • Loading branch information
rilrom and GermanJablo authored Nov 27, 2024
1 parent 3961223 commit 3c35d81
Show file tree
Hide file tree
Showing 4 changed files with 118 additions and 6 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [
Expand Down Expand Up @@ -55,6 +56,12 @@ const toolbarGroups: ToolbarGroup[] = [
]

export const IndentFeatureClient = createClientFeature({
plugins: [
{
Component: IndentPlugin,
position: 'normal',
},
],
toolbarFixed: {
groups: toolbarGroups,
},
Expand Down
Original file line number Diff line number Diff line change
@@ -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 />
}
5 changes: 1 addition & 4 deletions packages/richtext-lexical/src/lexical/LexicalEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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>
Expand Down Expand Up @@ -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 (
Expand Down
51 changes: 49 additions & 2 deletions test/fields/collections/Lexical/e2e/main/e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -784,8 +784,13 @@ describe('lexicalMain', () => {
})
})

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 () => {
/**
* 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()
Expand All @@ -799,6 +804,48 @@ describe('lexicalMain', () => {
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()
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()
/**
* Type some text
*/
Expand Down Expand Up @@ -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,
})
Expand Down

0 comments on commit 3c35d81

Please sign in to comment.