From eba41e47ad625017a724f8c476563113899f7dc5 Mon Sep 17 00:00:00 2001 From: LeSingh1 Date: Sat, 8 Aug 2026 22:23:31 -0700 Subject: [PATCH] [lexical-react] Bug Fix: a menu with no options no longer swallows the arrow keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description `LexicalMenu`'s arrow key handlers return `true` unconditionally, including on the path where the body never ran: ```tsx KEY_ARROW_DOWN_COMMAND, payload => { const event = payload; if (options !== null && options.length) { ... event.preventDefault(); event.stopImmediatePropagation(); } return true; // <- also returned when there are no options }, ``` `true` means the command was handled and must stop propagating. With an empty option list nothing is highlighted, nothing is scrolled, `preventDefault` is not even called — the key is simply eaten, and every lower-priority `KEY_ARROW_UP/DOWN_COMMAND` handler is skipped. That state is not hypothetical, and it is invisible. `defaultMenuRenderFn` renders `null` when there are no options — the existing test in this package is named *"should render nothing when options array is empty"* — while `LexicalTypeaheadMenuPlugin` and `LexicalNodeMenuPlugin` both render `` gated only on `resolution !== null`, never on `options.length`. So a trigger that is still matching but whose query filters everything out (async results not yet in, no match, a `menuRenderFn` that filters) leaves a resolved menu with zero options showing nothing. Typing `/` and then a query that matches no component in the playground's component picker is exactly this. Arrow Up/Down then do nothing at all: the caret will not move out of the line, and rich-text decorator/node-selection navigation and table arrow handling never see the key. The two sibling handlers registered in the same `mergeRegister` already get this right: ```tsx KEY_TAB_COMMAND, payload => { ... if (options === null || selectedIndex === null || options[selectedIndex] == null) { return false; } ``` This makes the arrow handlers agree with them: bail out with `false` when there is nothing to move through, and keep returning `true` on every path that actually moves the selection. The inner `if (!option)` branch, which consumes the key after resetting the index, is unchanged. The diff is larger than it reads because dropping the wrapping `if` re-indents the body; `git diff -w` is 12 insertions / 9 deletions. ## Test plan New unit test `packages/lexical-react/src/__tests__/unit/LexicalMenuEmptyOptions.test.tsx` (a separate file, so as not to collide with the a11y work in flight on `LexicalMenu.test.tsx`). For each of ArrowUp/ArrowDown it asserts both directions: the key propagates when there are no options, and is still consumed when there are — the second pair guards against over-narrowing and passes before and after. ### Before ``` ❯ packages/lexical-react/src/__tests__/unit/LexicalMenuEmptyOptions.test.tsx (4 tests | 2 failed) × lets ArrowDown through when there are no options AssertionError: expected true to be false // Object.is equality × lets ArrowUp through when there are no options AssertionError: expected true to be false // Object.is equality ✓ still consumes ArrowDown when there are options ✓ still consumes ArrowUp when there are options Test Files 1 failed (1) Tests 2 failed | 2 passed (4) ``` ### After ``` Test Files 1 passed (1) Tests 4 passed (4) ``` Package suite is unchanged: ``` $ npx vitest run packages/lexical-react Test Files 32 passed (32) Tests 186 passed (186) ``` --- .../unit/LexicalMenuEmptyOptions.test.tsx | 147 ++++++++++++++++++ .../lexical-react/src/shared/LexicalMenu.tsx | 99 ++++++------ 2 files changed, 198 insertions(+), 48 deletions(-) create mode 100644 packages/lexical-react/src/__tests__/unit/LexicalMenuEmptyOptions.test.tsx diff --git a/packages/lexical-react/src/__tests__/unit/LexicalMenuEmptyOptions.test.tsx b/packages/lexical-react/src/__tests__/unit/LexicalMenuEmptyOptions.test.tsx new file mode 100644 index 00000000000..eeeff0c5a04 --- /dev/null +++ b/packages/lexical-react/src/__tests__/unit/LexicalMenuEmptyOptions.test.tsx @@ -0,0 +1,147 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { + COMMAND_PRIORITY_EDITOR, + KEY_ARROW_DOWN_COMMAND, + KEY_ARROW_UP_COMMAND, + type LexicalCommand, + type LexicalEditor, +} from 'lexical'; +import {createTestEditor} from 'lexical/src/__tests__/utils'; +import * as React from 'react'; +import {act} from 'react'; +import {createRoot, type Root} from 'react-dom/client'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; + +import { + LexicalMenu, + MenuOption, + type MenuResolution, +} from '../../shared/LexicalMenu'; + +vi.mock('@lexical/react/LexicalComposerContext', () => ({ + useLexicalComposerContext: () => [createTestEditor()], +})); + +class TestOption extends MenuOption { + title: string; + constructor(title: string) { + super(title); + this.title = title; + } +} + +function createTestResolution(): MenuResolution { + return { + getRect: () => + ({ + bottom: 100, + height: 20, + left: 10, + right: 110, + top: 80, + width: 100, + x: 10, + y: 80, + }) as DOMRect, + match: {leadOffset: 0, matchingString: 'zz', replaceableString: 'zz'}, + }; +} + +describe('LexicalMenu arrow keys with no options', () => { + let container: HTMLDivElement; + let anchorElement: HTMLDivElement; + let rootElement: HTMLDivElement; + let reactRoot: Root; + let editor: LexicalEditor; + + beforeEach(() => { + container = document.createElement('div'); + anchorElement = document.createElement('div'); + rootElement = document.createElement('div'); + rootElement.contentEditable = 'true'; + document.body.append(container, anchorElement, rootElement); + reactRoot = createRoot(container); + editor = createTestEditor(); + editor.setRootElement(rootElement); + }); + + afterEach(async () => { + await act(async () => { + reactRoot.unmount(); + }); + container.remove(); + anchorElement.remove(); + rootElement.remove(); + vi.restoreAllMocks(); + }); + + async function renderMenu(options: TestOption[]): Promise { + await act(async () => { + reactRoot.render( + + close={vi.fn()} + editor={editor} + anchorElementRef={{current: anchorElement}} + resolution={createTestResolution()} + options={options} + onSelectOption={vi.fn()} + />, + ); + }); + } + + function pressKey(command: LexicalCommand): { + handled: boolean; + defaultPrevented: boolean; + reachedEditor: boolean; + } { + let reachedEditor = false; + const removeFallback = editor.registerCommand( + command, + () => { + reachedEditor = true; + return false; + }, + COMMAND_PRIORITY_EDITOR, + ); + const event = new KeyboardEvent('keydown', {cancelable: true}); + try { + const handled = editor.dispatchCommand(command, event); + return {defaultPrevented: event.defaultPrevented, handled, reachedEditor}; + } finally { + removeFallback(); + } + } + + for (const [name, command] of [ + ['ArrowDown', KEY_ARROW_DOWN_COMMAND], + ['ArrowUp', KEY_ARROW_UP_COMMAND], + ] as const) { + it(`lets ${name} through when there are no options`, async () => { + // An empty option list renders no menu at all, so the key must still + // reach whatever would otherwise move the caret. + await renderMenu([]); + + const result = pressKey(command); + expect(result.handled).toBe(false); + expect(result.defaultPrevented).toBe(false); + expect(result.reachedEditor).toBe(true); + }); + + it(`still consumes ${name} when there are options`, async () => { + await renderMenu([new TestOption('a'), new TestOption('b')]); + + const result = pressKey(command); + expect(result.handled).toBe(true); + expect(result.defaultPrevented).toBe(true); + expect(result.reachedEditor).toBe(false); + }); + } +}); diff --git a/packages/lexical-react/src/shared/LexicalMenu.tsx b/packages/lexical-react/src/shared/LexicalMenu.tsx index 0559926a4ff..d3c22981c01 100644 --- a/packages/lexical-react/src/shared/LexicalMenu.tsx +++ b/packages/lexical-react/src/shared/LexicalMenu.tsx @@ -470,36 +470,37 @@ export function LexicalMenu({ KEY_ARROW_DOWN_COMMAND, payload => { const event = payload; - if (options !== null && options.length) { - const newSelectedIndex = - selectedIndex === null - ? 0 - : selectedIndex !== options.length - 1 - ? selectedIndex + 1 - : 0; - - updateSelectedIndex(newSelectedIndex); - - const option = options[newSelectedIndex]; - if (!option) { - updateSelectedIndex(-1); - event.preventDefault(); - event.stopImmediatePropagation(); - return true; - } - - if (option.ref && option.ref.current) { - editor.dispatchCommand( - SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND, - { - index: newSelectedIndex, - option, - }, - ); - } + if (options === null || !options.length) { + // There is nothing to move through, and an empty option list + // renders no menu, so the key has to keep propagating to whatever + // would otherwise move the caret. + return false; + } + const newSelectedIndex = + selectedIndex === null + ? 0 + : selectedIndex !== options.length - 1 + ? selectedIndex + 1 + : 0; + + updateSelectedIndex(newSelectedIndex); + + const option = options[newSelectedIndex]; + if (!option) { + updateSelectedIndex(-1); event.preventDefault(); event.stopImmediatePropagation(); + return true; + } + + if (option.ref && option.ref.current) { + editor.dispatchCommand(SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND, { + index: newSelectedIndex, + option, + }); } + event.preventDefault(); + event.stopImmediatePropagation(); return true; }, commandPriority, @@ -508,30 +509,32 @@ export function LexicalMenu({ KEY_ARROW_UP_COMMAND, payload => { const event = payload; - if (options !== null && options.length) { - const newSelectedIndex = - selectedIndex === null - ? options.length - 1 - : selectedIndex !== 0 - ? selectedIndex - 1 - : options.length - 1; - - updateSelectedIndex(newSelectedIndex); - - const option = options[newSelectedIndex]; - if (!option) { - updateSelectedIndex(-1); - event.preventDefault(); - event.stopImmediatePropagation(); - return true; - } - - if (option.ref && option.ref.current) { - scrollIntoViewIfNeeded(option.ref.current); - } + if (options === null || !options.length) { + // See KEY_ARROW_DOWN_COMMAND above. + return false; + } + const newSelectedIndex = + selectedIndex === null + ? options.length - 1 + : selectedIndex !== 0 + ? selectedIndex - 1 + : options.length - 1; + + updateSelectedIndex(newSelectedIndex); + + const option = options[newSelectedIndex]; + if (!option) { + updateSelectedIndex(-1); event.preventDefault(); event.stopImmediatePropagation(); + return true; } + + if (option.ref && option.ref.current) { + scrollIntoViewIfNeeded(option.ref.current); + } + event.preventDefault(); + event.stopImmediatePropagation(); return true; }, commandPriority,