Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<void> {
await act(async () => {
reactRoot.render(
<LexicalMenu<TestOption>
close={vi.fn()}
editor={editor}
anchorElementRef={{current: anchorElement}}
resolution={createTestResolution()}
options={options}
onSelectOption={vi.fn()}
/>,
);
});
}

function pressKey(command: LexicalCommand<KeyboardEvent>): {
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);
});
}
});
99 changes: 51 additions & 48 deletions packages/lexical-react/src/shared/LexicalMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -470,36 +470,37 @@ export function LexicalMenu<TOption extends MenuOption>({
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,
Expand All @@ -508,30 +509,32 @@ export function LexicalMenu<TOption extends MenuOption>({
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,
Expand Down
Loading