fix(richtext-lexical): slash menu keyboard navigation not triggering auto-scroll (#7185)

`ref` was not added to internal slash menu items correctly.

Works as expected now:
![CleanShot 2024-07-16 at 22 05
41](https://github.com/user-attachments/assets/cfb32ec8-a449-41a7-a556-1e5ac365c6bc)
This commit is contained in:
Alessio Gravili
2024-07-16 22:30:04 -04:00
committed by GitHub
parent 8fdd88bd66
commit fe23ca5b1a
4 changed files with 60 additions and 83 deletions

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import type { LexicalCommand, LexicalEditor, TextNode } from 'lexical' import type { LexicalCommand, LexicalEditor, TextNode } from 'lexical'
import type { JSX, MutableRefObject, ReactPortal } from 'react' import type { JSX, ReactPortal, RefObject } from 'react'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext.js' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext.js'
import { mergeRegister } from '@lexical/utils' import { mergeRegister } from '@lexical/utils'
@@ -28,7 +28,7 @@ export type MenuResolution = {
const baseClass = 'slash-menu-popup' const baseClass = 'slash-menu-popup'
export type MenuRenderFn = ( export type MenuRenderFn = (
anchorElementRef: MutableRefObject<HTMLElement | null>, anchorElementRef: RefObject<HTMLElement | null>,
itemProps: { itemProps: {
groups: Array<SlashMenuGroupInternal> groups: Array<SlashMenuGroupInternal>
selectItemAndCleanUp: (selectedItem: SlashMenuItem) => void selectItemAndCleanUp: (selectedItem: SlashMenuItem) => void
@@ -206,7 +206,7 @@ export function LexicalMenu({
resolution, resolution,
shouldSplitNodeWithQuery = false, shouldSplitNodeWithQuery = false,
}: { }: {
anchorElementRef: MutableRefObject<HTMLElement> anchorElementRef: RefObject<HTMLElement>
close: () => void close: () => void
editor: LexicalEditor editor: LexicalEditor
groups: Array<SlashMenuGroupInternal> groups: Array<SlashMenuGroupInternal>
@@ -434,7 +434,7 @@ export function useMenuAnchorRef(
resolution: MenuResolution | null, resolution: MenuResolution | null,
setResolution: (r: MenuResolution | null) => void, setResolution: (r: MenuResolution | null) => void,
className?: string, className?: string,
): MutableRefObject<HTMLElement> { ): RefObject<HTMLElement> {
const [editor] = useLexicalComposerContext() const [editor] = useLexicalComposerContext()
const anchorElementRef = useRef<HTMLElement>(document.createElement('div')) const anchorElementRef = useRef<HTMLElement>(document.createElement('div'))
const positionMenu = useCallback(() => { const positionMenu = useCallback(() => {

View File

@@ -21,7 +21,7 @@ import * as React from 'react'
import type { MenuTextMatch, TriggerFn } from '../useMenuTriggerMatch.js' import type { MenuTextMatch, TriggerFn } from '../useMenuTriggerMatch.js'
import type { MenuRenderFn, MenuResolution } from './LexicalMenu.js' import type { MenuRenderFn, MenuResolution } from './LexicalMenu.js'
import type { SlashMenuGroup, SlashMenuGroupInternal, SlashMenuItem } from './types.js' import type { SlashMenuGroupInternal, SlashMenuItem } from './types.js'
import { LexicalMenu, useMenuAnchorRef } from './LexicalMenu.js' import { LexicalMenu, useMenuAnchorRef } from './LexicalMenu.js'
@@ -100,29 +100,6 @@ function startTransition(callback: () => void) {
} }
} }
// Got from https://stackoverflow.com/a/42543908/2013580
export function getScrollParent(
element: HTMLElement,
includeHidden: boolean,
): HTMLBodyElement | HTMLElement {
let style = getComputedStyle(element)
const excludeStaticParent = style.position === 'absolute'
const overflowRegex = includeHidden ? /(auto|scroll|hidden)/ : /(auto|scroll)/
if (style.position === 'fixed') {
return document.body
}
for (let parent: HTMLElement | null = element; (parent = parent.parentElement); ) {
style = getComputedStyle(parent)
if (excludeStaticParent && style.position === 'static') {
continue
}
if (overflowRegex.test(style.overflow + style.overflowY + style.overflowX)) {
return parent
}
}
return document.body
}
export { useDynamicPositioning } from './LexicalMenu.js' export { useDynamicPositioning } from './LexicalMenu.js'
export type TypeaheadMenuPluginProps = { export type TypeaheadMenuPluginProps = {
@@ -190,7 +167,7 @@ export function LexicalTypeaheadMenuPlugin({
matchingString: '', matchingString: '',
replaceableString: '', replaceableString: '',
} }
if (match !== null && !isSelectionOnEntityBoundary(editor, match.leadOffset)) { if (!isSelectionOnEntityBoundary(editor, match.leadOffset)) {
if (node !== null) { if (node !== null) {
const editorWindow = editor._window ?? window const editorWindow = editor._window ?? window
const range = editorWindow.document.createRange() const range = editorWindow.document.createRange()

View File

@@ -1,7 +1,7 @@
import type { I18nClient } from '@payloadcms/translations' import type { I18nClient } from '@payloadcms/translations'
import type { LexicalEditor, Spread } from 'lexical' import type { LexicalEditor, Spread } from 'lexical'
import type { MutableRefObject } from 'react'
import type React from 'react' import type React from 'react'
import type { RefObject } from 'react'
export type SlashMenuItem = { export type SlashMenuItem = {
/** The icon which is rendered in your slash menu item. */ /** The icon which is rendered in your slash menu item. */
@@ -34,7 +34,7 @@ export type SlashMenuGroup = {
} }
export type SlashMenuItemInternal = { export type SlashMenuItemInternal = {
ref: MutableRefObject<HTMLButtonElement | null> ref: RefObject<HTMLButtonElement | null>
} & SlashMenuItem } & SlashMenuItem
export type SlashMenuGroupInternal = Spread< export type SlashMenuGroupInternal = Spread<

View File

@@ -57,7 +57,9 @@ function SlashMenuItem({
key={item.key} key={item.key}
onClick={onClick} onClick={onClick}
onMouseEnter={onMouseEnter} onMouseEnter={onMouseEnter}
ref={item.ref} ref={(element) => {
item.ref = { current: element }
}}
role="option" role="option"
tabIndex={-1} tabIndex={-1}
type="button" type="button"
@@ -178,57 +180,55 @@ export function SlashMenuPlugin({
) )
return ( return (
<React.Fragment> <LexicalTypeaheadMenuPlugin
<LexicalTypeaheadMenuPlugin anchorElem={anchorElem}
anchorElem={anchorElem} groups={groups as SlashMenuGroupInternal[]}
groups={groups as SlashMenuGroupInternal[]} menuRenderFn={(
menuRenderFn={( anchorElementRef,
anchorElementRef, { selectItemAndCleanUp, selectedItemKey, setSelectedItemKey },
{ selectItemAndCleanUp, selectedItemKey, setSelectedItemKey }, ) =>
) => anchorElementRef.current && groups.length
anchorElementRef.current && groups.length ? ReactDOM.createPortal(
? ReactDOM.createPortal( <div className={baseClass}>
<div className={baseClass}> {groups.map((group) => {
{groups.map((group) => { let groupTitle = group.key
let groupTitle = group.key if (group.label) {
if (group.label) { groupTitle =
groupTitle = typeof group.label === 'function' ? group.label({ i18n }) : group.label
typeof group.label === 'function' ? group.label({ i18n }) : group.label }
}
return ( return (
<div <div
className={`${baseClass}__group ${baseClass}__group-${group.key}`} className={`${baseClass}__group ${baseClass}__group-${group.key}`}
key={group.key} key={group.key}
> >
<div className={`${baseClass}__group-title`}>{groupTitle}</div> <div className={`${baseClass}__group-title`}>{groupTitle}</div>
{group.items.map((item, oi: number) => ( {group.items.map((item, oi: number) => (
<SlashMenuItem <SlashMenuItem
index={oi} index={oi}
isSelected={selectedItemKey === item.key} isSelected={selectedItemKey === item.key}
item={item as SlashMenuItemInternal} item={item as SlashMenuItemInternal}
key={item.key} key={item.key}
onClick={() => { onClick={() => {
setSelectedItemKey(item.key) setSelectedItemKey(item.key)
selectItemAndCleanUp(item) selectItemAndCleanUp(item)
}} }}
onMouseEnter={() => { onMouseEnter={() => {
setSelectedItemKey(item.key) setSelectedItemKey(item.key)
}} }}
/> />
))} ))}
</div> </div>
) )
})} })}
</div>, </div>,
anchorElementRef.current, anchorElementRef.current,
) )
: null : null
} }
onQueryChange={setQueryString} onQueryChange={setQueryString}
onSelectItem={onSelectItem} onSelectItem={onSelectItem}
triggerFn={checkForTriggerMatch} triggerFn={checkForTriggerMatch}
/> />
</React.Fragment>
) )
} }