fix(richtext-lexical): toolbar dropdown items are disabled when selecting via double-click (#13544)

Fixes https://github.com/payloadcms/payload/issues/13275 by ensuring
that toolbar styles are updated on mount.

This PR also improves the lexical test suite by adding data attributes
that can be targeted more easily

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1211110462564657
This commit is contained in:
Alessio Gravili
2025-08-22 01:44:55 -07:00
committed by GitHub
parent ddb8ca4de2
commit 3bc1d0895f
9 changed files with 142 additions and 12 deletions

View File

@@ -116,7 +116,11 @@ function ToolbarGroupComponent({
)
return (
<div className={`fixed-toolbar__group fixed-toolbar__group-${group.key}`} key={group.key}>
<div
className={`fixed-toolbar__group fixed-toolbar__group-${group.key}`}
data-toolbar-group-key={group.key}
key={group.key}
>
{group.type === 'dropdown' && group.items.length ? (
DropdownIcon ? (
<ToolbarDropdown

View File

@@ -94,6 +94,7 @@ function ToolbarGroupComponent({
return (
<div
className={`inline-toolbar-popup__group inline-toolbar-popup__group-${group.key}`}
data-toolbar-group-key={group.key}
key={group.key}
>
{group.type === 'dropdown' && group.items.length ? (

View File

@@ -63,6 +63,9 @@ export const ToolbarButton = ({
const runDeprioritized = useRunDeprioritized()
useEffect(() => {
// Run on mount
void runDeprioritized(updateStates)
const listener = () => runDeprioritized(updateStates)
const cleanup = mergeRegister(editor.registerUpdateListener(listener))
@@ -99,7 +102,13 @@ export const ToolbarButton = ({
}, [])
return (
<button className={className} onClick={handleClick} onMouseDown={handleMouseDown} type="button">
<button
className={className}
data-button-key={item.key}
onClick={handleClick}
onMouseDown={handleMouseDown}
type="button"
>
{children}
</button>
)

View File

@@ -21,6 +21,7 @@ export function DropDownItem({
enabled,
Icon,
item,
itemKey,
tooltip,
}: {
active?: boolean
@@ -29,6 +30,7 @@ export function DropDownItem({
enabled?: boolean
Icon: React.ReactNode
item: ToolbarGroupItem
itemKey: string
tooltip?: string
}): React.ReactNode {
const className = useMemo(() => {
@@ -64,6 +66,9 @@ export function DropDownItem({
buttonStyle="none"
className={className}
disabled={enabled === false}
extraButtonProps={{
'data-item-key': itemKey,
}}
icon={Icon}
iconPosition="left"
iconStyle="none"
@@ -183,6 +188,7 @@ export function DropDown({
buttonClassName,
children,
disabled = false,
dropdownKey,
Icon,
itemsContainerClassNames,
label,
@@ -192,6 +198,7 @@ export function DropDown({
buttonClassName: string
children: ReactNode
disabled?: boolean
dropdownKey: string
Icon?: React.FC
itemsContainerClassNames?: string[]
label?: string
@@ -262,6 +269,7 @@ export function DropDown({
<button
aria-label={buttonAriaLabel}
className={buttonClassName + (showDropDown ? ' active' : '')}
data-dropdown-key={dropdownKey}
disabled={disabled}
onClick={(event) => {
event.preventDefault()

View File

@@ -71,6 +71,7 @@ const ToolbarItem = ({
enabled={enabled}
Icon={item?.ChildComponent ? <item.ChildComponent /> : undefined}
item={item}
itemKey={item.key}
key={item.key}
tooltip={title}
>
@@ -166,6 +167,9 @@ export const ToolbarDropdown = ({
}, [editor, editorConfigContext, group, items, maxActiveItems, onActiveChange])
useEffect(() => {
// Run on mount in order to update states when dropdown is opened
void runDeprioritized(updateStates)
return mergeRegister(
editor.registerUpdateListener(async () => {
await runDeprioritized(updateStates)
@@ -195,6 +199,7 @@ export const ToolbarDropdown = ({
.filter(Boolean)
.join(' ')}
disabled={!deferredToolbarState.enabledGroup}
dropdownKey={groupKey}
Icon={Icon}
itemsContainerClassNames={[`${baseClass}-items`, ...(itemsContainerClassNames || [])]}
key={groupKey}

View File

@@ -59,6 +59,7 @@ export const Button: React.FC<Props> = (props) => {
disabled,
el = 'button',
enableSubMenu,
extraButtonProps = {},
icon,
iconPosition = 'right',
iconStyle = 'without-border',
@@ -125,6 +126,7 @@ export const Button: React.FC<Props> = (props) => {
rel: newTab ? 'noopener noreferrer' : undefined,
target: newTab ? '_blank' : undefined,
title: ariaLabel,
...extraButtonProps,
}
let buttonElement

View File

@@ -27,6 +27,7 @@ export type Props = {
* Setting to `true` will allow the submenu to be opened when the button is disabled
*/
enableSubMenu?: boolean
extraButtonProps?: Record<string, any>
icon?: ['chevron' | 'edit' | 'plus' | 'x'] | React.ReactNode
iconPosition?: 'left' | 'right'
iconStyle?: 'none' | 'with-border' | 'without-border'

View File

@@ -23,6 +23,7 @@ const { serverURL } = await initPayloadE2ENoConfig({
})
describe('Lexical Fully Featured', () => {
let lexical: LexicalHelpers
beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG)
process.env.SEED_IN_CONFIG_ONINIT = 'false' // Makes it so the payload config onInit seed is not run. Otherwise, the seed would be run unnecessarily twice for the initial test run - once for beforeEach and once for onInit
@@ -37,14 +38,13 @@ describe('Lexical Fully Featured', () => {
uploadsDir: [path.resolve(dirname, './collections/Upload/uploads')],
})
const url = new AdminUrlUtil(serverURL, lexicalFullyFeaturedSlug)
const lexical = new LexicalHelpers(page)
lexical = new LexicalHelpers(page)
await page.goto(url.create)
await lexical.editor.first().focus()
})
test('prevent extra paragraph when inserting decorator blocks like blocks or upload node', async ({
page,
}) => {
const lexical = new LexicalHelpers(page)
await lexical.slashCommand('block')
await lexical.slashCommand('relationship')
await lexical.drawer.locator('.list-drawer__header').getByText('Create New').click()
@@ -68,7 +68,6 @@ describe('Lexical Fully Featured', () => {
test('ControlOrMeta+A inside input should select all the text inside the input', async ({
page,
}) => {
const lexical = new LexicalHelpers(page)
await lexical.editor.first().focus()
await page.keyboard.type('Hello')
await page.keyboard.press('Enter')
@@ -85,15 +84,50 @@ describe('Lexical Fully Featured', () => {
test('text state feature', async ({ page }) => {
await page.keyboard.type('Hello')
await page.keyboard.press('ControlOrMeta+A')
await page.locator('.toolbar-popup__dropdown-textState').first().click()
await page.getByRole('button', { name: 'Red' }).first().click()
await lexical.clickInlineToolbarButton({
dropdownKey: 'textState',
buttonKey: 'bg-red',
})
const colored = page.locator('span').filter({ hasText: 'Hello' })
await expect(colored).toHaveCSS('background-color', 'oklch(0.704 0.191 22.216)')
await expect(colored).toHaveAttribute('data-color', 'bg-red')
await page.locator('.toolbar-popup__dropdown-textState').first().click()
await page.getByRole('button', { name: 'Default style' }).click()
await expect(colored).toHaveAttribute('data-background-color', 'bg-red')
await lexical.clickInlineToolbarButton({
dropdownKey: 'textState',
buttonKey: 'clear-style',
})
await expect(colored).toBeVisible()
await expect(colored).not.toHaveCSS('background-color', 'oklch(0.704 0.191 22.216)')
await expect(colored).not.toHaveAttribute('data-color', 'bg-red')
await expect(colored).not.toHaveAttribute('data-background-color', 'bg-red')
})
test('ensure inline toolbar items are updated when selecting word by double-clicking', async ({
page,
}) => {
await page.keyboard.type('Hello')
await page.getByText('Hello').first().dblclick()
const { dropdownItems } = await lexical.clickInlineToolbarButton({
dropdownKey: 'textState',
})
const someButton = dropdownItems!.locator(`[data-item-key="bg-red"]`)
await expect(someButton).toHaveAttribute('aria-disabled', 'false')
})
test('ensure fixed toolbar items are updated when selecting word by double-clicking', async ({
page,
}) => {
await page.keyboard.type('Hello')
await page.getByText('Hello').first().dblclick()
const { dropdownItems } = await lexical.clickFixedToolbarButton({
dropdownKey: 'textState',
})
const someButton = dropdownItems!.locator(`[data-item-key="bg-red"]`)
await expect(someButton).toHaveAttribute('aria-disabled', 'false')
})
})

View File

@@ -1,4 +1,4 @@
import type { Page } from 'playwright'
import type { Locator, Page } from 'playwright'
import { expect } from '@playwright/test'
@@ -29,6 +29,64 @@ export class LexicalHelpers {
await this.page.keyboard.type(text)
}
async clickFixedToolbarButton({
buttonKey,
dropdownKey,
}: {
buttonKey?: string
dropdownKey?: string
}): Promise<{
dropdownItems?: Locator
}> {
if (dropdownKey) {
await this.fixedToolbar.locator(`[data-dropdown-key="${dropdownKey}"]`).click()
const dropdownItems = this.page.locator(`.toolbar-popup__dropdown-items`)
await expect(dropdownItems).toBeVisible()
if (buttonKey) {
await dropdownItems.locator(`[data-item-key="${buttonKey}"]`).click()
}
return {
dropdownItems,
}
}
if (buttonKey) {
await this.fixedToolbar.locator(`[data-item-key="${buttonKey}"]`).click()
}
return {}
}
async clickInlineToolbarButton({
buttonKey,
dropdownKey,
}: {
buttonKey?: string
dropdownKey?: string
}): Promise<{
dropdownItems?: Locator
}> {
if (dropdownKey) {
await this.inlineToolbar.locator(`[data-dropdown-key="${dropdownKey}"]`).click()
const dropdownItems = this.page.locator(`.toolbar-popup__dropdown-items`)
await expect(dropdownItems).toBeVisible()
if (buttonKey) {
await dropdownItems.locator(`[data-item-key="${buttonKey}"]`).click()
}
return {
dropdownItems,
}
}
if (buttonKey) {
await this.inlineToolbar.locator(`[data-item-key="${buttonKey}"]`).click()
}
return {}
}
async save(container: 'document' | 'drawer') {
if (container === 'drawer') {
await this.drawer.getByText('Save').click()
@@ -64,6 +122,14 @@ export class LexicalHelpers {
return this.page.locator('[data-lexical-editor="true"]')
}
get fixedToolbar() {
return this.page.locator('.fixed-toolbar')
}
get inlineToolbar() {
return this.page.locator('.inline-toolbar-popup')
}
get paragraph() {
return this.editor.locator('p')
}