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:
@@ -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
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user