Skip to content

[lexical-react][lexical-playground] Bug Fix: Keep the arrow key highlight when a typeahead menu scrolls under the mouse - #9391

Open
Thanatos9404 wants to merge 1 commit into
meta:mainfrom
Thanatos9404:fix/typeahead-hover-keeps-keyboard-highlight
Open

Thanatos9404 wants to merge 1 commit into
meta:mainfrom
Thanatos9404:fix/typeahead-hover-keeps-keyboard-highlight

Conversation

@Thanatos9404

Copy link
Copy Markdown
Contributor

Description

Pressing ArrowDown or ArrowUp in a typeahead menu scrolls the highlighted option into view. With the mouse resting over the menu, the scroll slides another option under it, the browser fires mouseenter there, and the highlight jumps back to it.

The default menu now moves the highlight on mousemove, and only when the pointer position has changed, because WebKit also fires a mousemove after the scroll at the position the pointer never left. If the arrow keys come first, the first mousemove only records the position. Taps still highlight and select, since touch sends a mousemove before the click. Wheel scrolling and typing a filter no longer move the highlight until the mouse moves. The playground's li:hover style is removed so that only the highlighted row is grey. Menus with their own menuRenderFn are unchanged.

Closes #4296

Test plan

Before

$ pnpm exec vitest --project unit --no-watch packages/lexical-react/src/__tests__/unit/LexicalMenu.test.tsx
         × keeps the keyboard highlight when the list scrolls under a still pointer
         × keeps the ArrowDown highlight when the menu opened under a still pointer
         × keeps the ArrowUp highlight when the menu opened under a still pointer
AssertionError: expected 2 to be 1 // Object.is equality
AssertionError: expected 2 to be 1 // Object.is equality
AssertionError: expected 1 to be 2 // Object.is equality
      Tests  3 failed | 19 passed (22)

In the playground, resting the mouse on the third "/" option and pressing ArrowDown ten times (local Playwright script, highlighted index after each press):

chromium 153.0.8010.12: 3, 4, 5, 3, 4, 5, 6, 4, 5, 6
firefox 155.0: 3, 4, 5, 3, 4, 5, 6, 4, 5, 6
webkit 26.6: 3, 4, 5, 3, 4, 5, 6, 4, 5, 6

After

$ pnpm exec vitest --project unit --no-watch packages/lexical-react/src/__tests__/unit/LexicalMenu.test.tsx
      Tests  22 passed (22)

$ pnpm exec vitest --project unit --no-watch packages/lexical-react packages/lexical-playground
 Test Files  74 passed (74)
      Tests  674 passed (674)
chromium 153.0.8010.12: 3, 4, 5, 6, 7, 8, 9, 10, 11, 12
firefox 155.0: 3, 4, 5, 6, 7, 8, 9, 10, 11, 12
webkit 26.6: 3, 4, 5, 6, 7, 8, 9, 10, 11, 12

With mousemove but no position check, WebKit still jumped (3, 4, 2, 3, 4, 5, 3, 4, 5, 6). When the menu opens under a mouse that never moves, the highlight also follows the keys (1 to 10 in all three). Moving the mouse still highlights the option under it, and clicking an option without moving or tapping one with touch emulation still inserts it.

tsc, tsc-test, flow full-check (0 errors), and eslint and prettier --list-different on the changed files pass. The ComponentPicker, Mentions and DraggableBlock e2e specs pass in Chromium; the Firefox and WebKit failures I saw there also fail or flake on main. All of this ran on Windows with Node 24.21.0, so WebKit is Playwright's Windows build rather than Safari, and touch was only emulated.

…ight when a typeahead menu scrolls under the mouse

## Description

Pressing ArrowDown or ArrowUp in a typeahead menu scrolls the highlighted
option into view. With the mouse resting over the menu, the scroll slides
another option under it, the browser fires mouseenter there, and the
highlight jumps back to it.

The default menu now moves the highlight on mousemove, and only when the
pointer position has changed, because WebKit also fires a mousemove after the
scroll at the position the pointer never left. If the arrow keys come first,
the first mousemove only records the position. Taps still highlight and
select, since touch sends a mousemove before the click. Wheel scrolling and
typing a filter no longer move the highlight until the mouse moves. The
playground's `li:hover` style is removed so that only the highlighted row is
grey. Menus with their own `menuRenderFn` are unchanged.

Closes meta#4296

## Test plan

### Before

```
$ pnpm exec vitest --project unit --no-watch packages/lexical-react/src/__tests__/unit/LexicalMenu.test.tsx
         × keeps the keyboard highlight when the list scrolls under a still pointer
         × keeps the ArrowDown highlight when the menu opened under a still pointer
         × keeps the ArrowUp highlight when the menu opened under a still pointer
AssertionError: expected 2 to be 1 // Object.is equality
AssertionError: expected 2 to be 1 // Object.is equality
AssertionError: expected 1 to be 2 // Object.is equality
      Tests  3 failed | 19 passed (22)
```

In the playground, resting the mouse on the third "/" option and pressing
ArrowDown ten times (local Playwright script, highlighted index after each
press):

```
chromium 153.0.8010.12: 3, 4, 5, 3, 4, 5, 6, 4, 5, 6
firefox 155.0: 3, 4, 5, 3, 4, 5, 6, 4, 5, 6
webkit 26.6: 3, 4, 5, 3, 4, 5, 6, 4, 5, 6
```

### After

```
$ pnpm exec vitest --project unit --no-watch packages/lexical-react/src/__tests__/unit/LexicalMenu.test.tsx
      Tests  22 passed (22)

$ pnpm exec vitest --project unit --no-watch packages/lexical-react packages/lexical-playground
 Test Files  74 passed (74)
      Tests  674 passed (674)
```

```
chromium 153.0.8010.12: 3, 4, 5, 6, 7, 8, 9, 10, 11, 12
firefox 155.0: 3, 4, 5, 6, 7, 8, 9, 10, 11, 12
webkit 26.6: 3, 4, 5, 6, 7, 8, 9, 10, 11, 12
```

With mousemove but no position check, WebKit still jumped (3, 4, 2, 3, 4,
5, 3, 4, 5, 6). When the menu opens under a mouse that never moves, the
highlight also follows the keys (1 to 10 in all three). Moving the mouse
still highlights the option under it, and clicking an option without moving
or tapping one with touch emulation still inserts it.

`tsc`, `tsc-test`, `flow full-check` (0 errors), and `eslint` and
`prettier --list-different` on the changed files pass. The ComponentPicker,
Mentions and DraggableBlock e2e specs pass in Chromium; the Firefox and
WebKit failures I saw there also fail or flake on `main`. All of this ran on
Windows with Node 24.21.0, so WebKit is Playwright's Windows build rather
than Safari, and touch was only emulated.
@vercel

vercel Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
lexical Ready Ready Preview Oct 20, 2026 7:53am UTC
lexical-playground Ready Ready Preview Oct 20, 2026 7:53am UTC

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Oct 20, 2026
@pkg-pr-new

pkg-pr-new Bot commented Oct 20, 2026

Copy link
Copy Markdown
lexical

npm i https://pkg.pr.new/lexical@9391
pnpm add https://pkg.pr.new/lexical@9391
yarn add https://pkg.pr.new/[email protected]

@lexical/a11y

npm i https://pkg.pr.new/@lexical/a11y@9391
pnpm add https://pkg.pr.new/@lexical/a11y@9391
yarn add https://pkg.pr.new/@lexical/[email protected]

@lexical/clipboard

npm i https://pkg.pr.new/@lexical/clipboard@9391
pnpm add https://pkg.pr.new/@lexical/clipboard@9391
yarn add https://pkg.pr.new/@lexical/[email protected]

@lexical/code

npm i https://pkg.pr.new/@lexical/code@9391
pnpm add https://pkg.pr.new/@lexical/code@9391
yarn add https://pkg.pr.new/@lexical/[email protected]

@lexical/code-core

npm i https://pkg.pr.new/@lexical/code-core@9391
pnpm add https://pkg.pr.new/@lexical/code-core@9391
yarn add https://pkg.pr.new/@lexical/[email protected]

@lexical/code-prism

npm i https://pkg.pr.new/@lexical/code-prism@9391
pnpm add https://pkg.pr.new/@lexical/code-prism@9391
yarn add https://pkg.pr.new/@lexical/[email protected]

@lexical/code-shiki

npm i https://pkg.pr.new/@lexical/code-shiki@9391
pnpm add https://pkg.pr.new/@lexical/code-shiki@9391
yarn add https://pkg.pr.new/@lexical/[email protected]

@lexical/compiler

npm i https://pkg.pr.new/@lexical/compiler@9391
pnpm add https://pkg.pr.new/@lexical/compiler@9391
yarn add https://pkg.pr.new/@lexical/[email protected]

@lexical/devtools-core

npm i https://pkg.pr.new/@lexical/devtools-core@9391
pnpm add https://pkg.pr.new/@lexical/devtools-core@9391
yarn add https://pkg.pr.new/@lexical/[email protected]

@lexical/dragon

npm i https://pkg.pr.new/@lexical/dragon@9391
pnpm add https://pkg.pr.new/@lexical/dragon@9391
yarn add https://pkg.pr.new/@lexical/[email protected]

@lexical/eslint-plugin

npm i https://pkg.pr.new/@lexical/eslint-plugin@9391
pnpm add https://pkg.pr.new/@lexical/eslint-plugin@9391
yarn add https://pkg.pr.new/@lexical/[email protected]

@lexical/extension

npm i https://pkg.pr.new/@lexical/extension@9391
pnpm add https://pkg.pr.new/@lexical/extension@9391
yarn add https://pkg.pr.new/@lexical/[email protected]

@lexical/fast-check

npm i https://pkg.pr.new/@lexical/fast-check@9391
pnpm add https://pkg.pr.new/@lexical/fast-check@9391
yarn add https://pkg.pr.new/@lexical/[email protected]

@lexical/file

npm i https://pkg.pr.new/@lexical/file@9391
pnpm add https://pkg.pr.new/@lexical/file@9391
yarn add https://pkg.pr.new/@lexical/[email protected]

@lexical/hashtag

npm i https://pkg.pr.new/@lexical/hashtag@9391
pnpm add https://pkg.pr.new/@lexical/hashtag@9391
yarn add https://pkg.pr.new/@lexical/[email protected]

@lexical/headless

npm i https://pkg.pr.new/@lexical/headless@9391
pnpm add https://pkg.pr.new/@lexical/headless@9391
yarn add https://pkg.pr.new/@lexical/[email protected]

@lexical/history

npm i https://pkg.pr.new/@lexical/history@9391
pnpm add https://pkg.pr.new/@lexical/history@9391
yarn add https://pkg.pr.new/@lexical/[email protected]

@lexical/html

npm i https://pkg.pr.new/@lexical/html@9391
pnpm add https://pkg.pr.new/@lexical/html@9391
yarn add https://pkg.pr.new/@lexical/[email protected]

@lexical/internal

npm i https://pkg.pr.new/@lexical/internal@9391
pnpm add https://pkg.pr.new/@lexical/internal@9391
yarn add https://pkg.pr.new/@lexical/[email protected]

@lexical/link

npm i https://pkg.pr.new/@lexical/link@9391
pnpm add https://pkg.pr.new/@lexical/link@9391
yarn add https://pkg.pr.new/@lexical/[email protected]

@lexical/list

npm i https://pkg.pr.new/@lexical/list@9391
pnpm add https://pkg.pr.new/@lexical/list@9391
yarn add https://pkg.pr.new/@lexical/[email protected]

@lexical/mark

npm i https://pkg.pr.new/@lexical/mark@9391
pnpm add https://pkg.pr.new/@lexical/mark@9391
yarn add https://pkg.pr.new/@lexical/[email protected]

@lexical/markdown

npm i https://pkg.pr.new/@lexical/markdown@9391
pnpm add https://pkg.pr.new/@lexical/markdown@9391
yarn add https://pkg.pr.new/@lexical/[email protected]

@lexical/mdast

npm i https://pkg.pr.new/@lexical/mdast@9391
pnpm add https://pkg.pr.new/@lexical/mdast@9391
yarn add https://pkg.pr.new/@lexical/[email protected]

@lexical/offset

npm i https://pkg.pr.new/@lexical/offset@9391
pnpm add https://pkg.pr.new/@lexical/offset@9391
yarn add https://pkg.pr.new/@lexical/[email protected]

@lexical/overflow

npm i https://pkg.pr.new/@lexical/overflow@9391
pnpm add https://pkg.pr.new/@lexical/overflow@9391
yarn add https://pkg.pr.new/@lexical/[email protected]

@lexical/plain-text

npm i https://pkg.pr.new/@lexical/plain-text@9391
pnpm add https://pkg.pr.new/@lexical/plain-text@9391
yarn add https://pkg.pr.new/@lexical/[email protected]

@lexical/react

npm i https://pkg.pr.new/@lexical/react@9391
pnpm add https://pkg.pr.new/@lexical/react@9391
yarn add https://pkg.pr.new/@lexical/[email protected]

@lexical/rich-text

npm i https://pkg.pr.new/@lexical/rich-text@9391
pnpm add https://pkg.pr.new/@lexical/rich-text@9391
yarn add https://pkg.pr.new/@lexical/[email protected]

@lexical/selection

npm i https://pkg.pr.new/@lexical/selection@9391
pnpm add https://pkg.pr.new/@lexical/selection@9391
yarn add https://pkg.pr.new/@lexical/[email protected]

@lexical/table

npm i https://pkg.pr.new/@lexical/table@9391
pnpm add https://pkg.pr.new/@lexical/table@9391
yarn add https://pkg.pr.new/@lexical/[email protected]

@lexical/tailwind

npm i https://pkg.pr.new/@lexical/tailwind@9391
pnpm add https://pkg.pr.new/@lexical/tailwind@9391
yarn add https://pkg.pr.new/@lexical/[email protected]

@lexical/text

npm i https://pkg.pr.new/@lexical/text@9391
pnpm add https://pkg.pr.new/@lexical/text@9391
yarn add https://pkg.pr.new/@lexical/[email protected]

@lexical/utils

npm i https://pkg.pr.new/@lexical/utils@9391
pnpm add https://pkg.pr.new/@lexical/utils@9391
yarn add https://pkg.pr.new/@lexical/[email protected]

@lexical/yjs

npm i https://pkg.pr.new/@lexical/yjs@9391
pnpm add https://pkg.pr.new/@lexical/yjs@9391
yarn add https://pkg.pr.new/@lexical/[email protected]

commit: b46d3d4

@rootnode-bot rootnode-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks good, thanks! Tracking down WebKit's same-position mousemove is what makes the position check necessary rather than a plain switch to onMouseMove. Rerunning your scenario (pointer resting on the third / option, ArrowDown ten times) in Chromium, Firefox and WebKit reproduces the jump on main and shows the highlight advancing one option per press with this change, and the new unit tests fail without the fix and pass with it.

Two optional, non-blocking suggestions:

  1. A browser-level regression test. The new cases in packages/lexical-react/src/__tests__/unit/LexicalMenu.test.tsx run on jsdom with hand-built event sequences, so they pin the handler logic but not the browser behavior that triggers the bug. AGENTS.md (Testing Strategy) puts real input and layout in browser-mode or e2e tests; a case in packages/lexical-playground/__tests__/e2e/ComponentPicker.spec.mjs that hovers the third option, presses ArrowDown five times and asserts that the eighth option has .selected fails on main and would keep this fixed.
  2. A line in the description about the hover change. In the default menu, hover highlighting now needs pointer movement, and the first move after an arrow key only records the position (packages/lexical-react/src/shared/LexicalMenu.tsx:355-367). People using a mouse won't notice, but app test suites will: a synthetic mouseover/mouseenter alone no longer highlights an option, and a single-step Playwright hover() after ArrowDown leaves the keyboard highlight in place. AGENTS.md (Backwards Compatibility) asks us to assume external code depends on current behavior, so calling this out helps app authors who drive the menu from tests.

This branch was successfully deployed

2 active deployments
Preview – lexical — b46d3d4b Deployed Oct 20, 2026 by vercel[bot]
Preview – lexical-playground — b46d3d4b Deployed Oct 20, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: [TypeaheadMenuPlugin] Unexpected selectedIndex Jump to List Item Cursor is On When Navigating with Keydown/KeyUp

1 participant