From 7274ea223760b38f8e1cfb834fc9e464cf4b2f34 Mon Sep 17 00:00:00 2001 From: liuqiang Date: Tue, 9 Dec 2025 16:04:09 +0800 Subject: [PATCH 1/6] fix: No focus issue with scrolling --- src/DropdownMenu.tsx | 11 ++++- src/Mentions.tsx | 1 + src/MentionsContext.ts | 2 + tests/DropdownMenu.spec.tsx | 80 ++++++++++++++++++++++++++++--------- 4 files changed, 74 insertions(+), 20 deletions(-) diff --git a/src/DropdownMenu.tsx b/src/DropdownMenu.tsx index 8993216..6ae9d1d 100644 --- a/src/DropdownMenu.tsx +++ b/src/DropdownMenu.tsx @@ -22,6 +22,7 @@ function DropdownMenu(props: DropdownMenuProps) { onFocus, onBlur, onScroll, + textareaRef, } = React.useContext(MentionsContext); const { prefixCls, options, opened } = props; @@ -34,6 +35,14 @@ function DropdownMenu(props: DropdownMenuProps) { return; } + // 只有当焦点恰好位于当前文本区域时,才进行滚动操作。 + if ( + textareaRef?.current && + document.activeElement !== textareaRef.current.nativeElement + ) { + return; + } + const activeItem = menuRef.current?.findItem?.({ key: activeOption.key }); if (activeItem) { @@ -42,7 +51,7 @@ function DropdownMenu(props: DropdownMenuProps) { inline: 'nearest', }); } - }, [activeIndex, activeOption.key, opened]); + }, [activeIndex, activeOption.key, opened, textareaRef]); return ( ( onFocus: onDropdownFocus, onBlur: onDropdownBlur, onScroll: onInternalPopupScroll, + textareaRef, }} > ; onBlur: React.FocusEventHandler; onScroll: React.UIEventHandler; + textareaRef: React.MutableRefObject; } // We will never use default, here only to fix TypeScript warning diff --git a/tests/DropdownMenu.spec.tsx b/tests/DropdownMenu.spec.tsx index 2ab3ca5..24d980f 100644 --- a/tests/DropdownMenu.spec.tsx +++ b/tests/DropdownMenu.spec.tsx @@ -1,48 +1,54 @@ import React from 'react'; -import { render, act } from '@testing-library/react'; -import Mentions, { UnstableContext } from '../src'; +import { render, act, fireEvent } from '@testing-library/react'; +import Mentions from '../src'; import { simulateInput } from './util'; describe('DropdownMenu', () => { - // Generate 20 options for testing scrolling behavior const generateOptions = Array.from({ length: 20 }).map((_, index) => ({ value: `item-${index}`, label: `item-${index}`, })); + beforeAll(() => { + global.ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + }; + }); + beforeEach(() => { jest.useFakeTimers(); }); afterEach(() => { jest.useRealTimers(); + jest.clearAllMocks(); }); - it('should scroll into view when navigating with keyboard', async () => { - // Setup component with UnstableContext for testing dropdown behavior + it('should scroll into view when navigating with keyboard (Focused)', async () => { const { container } = render( - - - , + , ); - // Mock scrollIntoView since it's not implemented in JSDOM + const textarea = container.querySelector('textarea'); const scrollIntoViewMock = jest .spyOn(HTMLElement.prototype, 'scrollIntoView') .mockImplementation(jest.fn()); - // Trigger should not scroll - simulateInput(container, '@'); - expect(scrollIntoViewMock).not.toHaveBeenCalled(); + await act(async () => { + textarea.focus(); + + simulateInput(container, '@'); - for (let i = 0; i < 10; i++) { - await act(async () => { - jest.advanceTimersByTime(1000); - await Promise.resolve(); - }); - } + jest.runAllTimers(); + }); + + await act(async () => { + fireEvent.keyDown(textarea, { key: 'ArrowDown', code: 'ArrowDown' }); + jest.runAllTimers(); + }); - // Verify if scrollIntoView was called expect(scrollIntoViewMock).toHaveBeenCalledWith({ block: 'nearest', inline: 'nearest', @@ -50,4 +56,40 @@ describe('DropdownMenu', () => { scrollIntoViewMock.mockRestore(); }); + + it('should NOT scroll into view when input is NOT focused', async () => { + const { container } = render( + , + ); + + const textarea = container.querySelector('textarea'); + const scrollIntoViewMock = jest + .spyOn(HTMLElement.prototype, 'scrollIntoView') + .mockImplementation(jest.fn()); + + await act(async () => { + textarea.focus(); + simulateInput(container, '@'); + jest.runAllTimers(); + }); + + scrollIntoViewMock.mockClear(); + + await act(async () => { + fireEvent.blur(textarea); + jest.runAllTimers(); + }); + + await act(async () => { + const menuItems = document.querySelectorAll('.rc-mentions-menu-item'); + if (menuItems.length > 1) { + fireEvent.mouseEnter(menuItems[1]); + jest.runAllTimers(); + } + }); + + expect(scrollIntoViewMock).not.toHaveBeenCalled(); + + scrollIntoViewMock.mockRestore(); + }); }); From b75ddd3ce3385666231fbd0e53322f1d7e00837f Mon Sep 17 00:00:00 2001 From: liuqiang Date: Tue, 9 Dec 2025 16:10:11 +0800 Subject: [PATCH 2/6] update --- src/DropdownMenu.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/DropdownMenu.tsx b/src/DropdownMenu.tsx index 6ae9d1d..3cc7ded 100644 --- a/src/DropdownMenu.tsx +++ b/src/DropdownMenu.tsx @@ -35,7 +35,6 @@ function DropdownMenu(props: DropdownMenuProps) { return; } - // 只有当焦点恰好位于当前文本区域时,才进行滚动操作。 if ( textareaRef?.current && document.activeElement !== textareaRef.current.nativeElement From 1aa2da4173b1cc6218d991f32e60deeb4b8f8efe Mon Sep 17 00:00:00 2001 From: liuqiang Date: Tue, 9 Dec 2025 18:23:37 +0800 Subject: [PATCH 3/6] update --- tests/DropdownMenu.spec.tsx | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/tests/DropdownMenu.spec.tsx b/tests/DropdownMenu.spec.tsx index 24d980f..0db3204 100644 --- a/tests/DropdownMenu.spec.tsx +++ b/tests/DropdownMenu.spec.tsx @@ -57,7 +57,7 @@ describe('DropdownMenu', () => { scrollIntoViewMock.mockRestore(); }); - it('should NOT scroll into view when input is NOT focused', async () => { + it('should NOT scroll and hit the return statement when input blurs but menu is arguably open', async () => { const { container } = render( , ); @@ -73,19 +73,15 @@ describe('DropdownMenu', () => { jest.runAllTimers(); }); - scrollIntoViewMock.mockClear(); - await act(async () => { fireEvent.blur(textarea); - jest.runAllTimers(); - }); - await act(async () => { const menuItems = document.querySelectorAll('.rc-mentions-menu-item'); if (menuItems.length > 1) { fireEvent.mouseEnter(menuItems[1]); - jest.runAllTimers(); } + + jest.runAllTimers(); }); expect(scrollIntoViewMock).not.toHaveBeenCalled(); From a48468fe70058ba47c6f5851565f3700aadc9449 Mon Sep 17 00:00:00 2001 From: liuqiang Date: Tue, 9 Dec 2025 18:31:05 +0800 Subject: [PATCH 4/6] update --- tests/DropdownMenu.spec.tsx | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/tests/DropdownMenu.spec.tsx b/tests/DropdownMenu.spec.tsx index 0db3204..cdcdca4 100644 --- a/tests/DropdownMenu.spec.tsx +++ b/tests/DropdownMenu.spec.tsx @@ -9,21 +9,27 @@ describe('DropdownMenu', () => { label: `item-${index}`, })); + let originResizeObserver: any; beforeAll(() => { - global.ResizeObserver = class ResizeObserver { + originResizeObserver = (global as any).ResizeObserver; + (global as any).ResizeObserver = class ResizeObserver { observe() {} unobserve() {} disconnect() {} }; }); + afterAll(() => { + (global as any).ResizeObserver = originResizeObserver; + }); + beforeEach(() => { jest.useFakeTimers(); }); afterEach(() => { jest.useRealTimers(); - jest.clearAllMocks(); + jest.restoreAllMocks(); }); it('should scroll into view when navigating with keyboard (Focused)', async () => { @@ -76,10 +82,9 @@ describe('DropdownMenu', () => { await act(async () => { fireEvent.blur(textarea); - const menuItems = document.querySelectorAll('.rc-mentions-menu-item'); - if (menuItems.length > 1) { - fireEvent.mouseEnter(menuItems[1]); - } + const menuItems = container.querySelectorAll('.rc-mentions-menu-item'); + expect(menuItems.length).toBeGreaterThan(1); + fireEvent.mouseEnter(menuItems[1]); jest.runAllTimers(); }); From f3890b1a4b4882989b87713f886411d37b09bb3d Mon Sep 17 00:00:00 2001 From: liuqiang Date: Thu, 11 Dec 2025 16:57:41 +0800 Subject: [PATCH 5/6] update --- tests/DropdownMenu.spec.tsx | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/tests/DropdownMenu.spec.tsx b/tests/DropdownMenu.spec.tsx index cdcdca4..e31f89b 100644 --- a/tests/DropdownMenu.spec.tsx +++ b/tests/DropdownMenu.spec.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { render, act, fireEvent } from '@testing-library/react'; +import { render, act, fireEvent, waitFor } from '@testing-library/react'; import Mentions from '../src'; import { simulateInput } from './util'; @@ -79,13 +79,21 @@ describe('DropdownMenu', () => { jest.runAllTimers(); }); - await act(async () => { - fireEvent.blur(textarea); - - const menuItems = container.querySelectorAll('.rc-mentions-menu-item'); + await waitFor(() => { + const menuItems = document.querySelectorAll( + '.rc-mentions-dropdown-menu-item', + ); expect(menuItems.length).toBeGreaterThan(1); + }); + + const menuItems = document.querySelectorAll( + '.rc-mentions-dropdown-menu-item', + ); + + await act(async () => { fireEvent.mouseEnter(menuItems[1]); + fireEvent.blur(textarea); jest.runAllTimers(); }); From 5fea6da54175efca204c82a63303da85055f63bd Mon Sep 17 00:00:00 2001 From: liuqiang Date: Fri, 12 Dec 2025 13:09:47 +0800 Subject: [PATCH 6/6] update test --- tests/DropdownMenu.spec.tsx | 86 +++++++++++++------------------------ 1 file changed, 30 insertions(+), 56 deletions(-) diff --git a/tests/DropdownMenu.spec.tsx b/tests/DropdownMenu.spec.tsx index e31f89b..89ec8ef 100644 --- a/tests/DropdownMenu.spec.tsx +++ b/tests/DropdownMenu.spec.tsx @@ -1,60 +1,48 @@ import React from 'react'; -import { render, act, fireEvent, waitFor } from '@testing-library/react'; -import Mentions from '../src'; +import { render, act } from '@testing-library/react'; +import Mentions, { UnstableContext } from '../src'; import { simulateInput } from './util'; describe('DropdownMenu', () => { + // Generate 20 options for testing scrolling behavior const generateOptions = Array.from({ length: 20 }).map((_, index) => ({ value: `item-${index}`, label: `item-${index}`, })); - let originResizeObserver: any; - beforeAll(() => { - originResizeObserver = (global as any).ResizeObserver; - (global as any).ResizeObserver = class ResizeObserver { - observe() {} - unobserve() {} - disconnect() {} - }; - }); - - afterAll(() => { - (global as any).ResizeObserver = originResizeObserver; - }); - beforeEach(() => { jest.useFakeTimers(); }); afterEach(() => { jest.useRealTimers(); - jest.restoreAllMocks(); }); - it('should scroll into view when navigating with keyboard (Focused)', async () => { + it('should scroll into view when navigating with keyboard', async () => { + // Setup component with UnstableContext for testing dropdown behavior const { container } = render( - , + + + , ); - const textarea = container.querySelector('textarea'); + // Mock scrollIntoView since it's not implemented in JSDOM const scrollIntoViewMock = jest .spyOn(HTMLElement.prototype, 'scrollIntoView') .mockImplementation(jest.fn()); - await act(async () => { - textarea.focus(); - - simulateInput(container, '@'); - - jest.runAllTimers(); - }); + // Trigger should not scroll + simulateInput(container, '@'); + expect(scrollIntoViewMock).not.toHaveBeenCalled(); - await act(async () => { - fireEvent.keyDown(textarea, { key: 'ArrowDown', code: 'ArrowDown' }); - jest.runAllTimers(); - }); + for (let i = 0; i < 10; i++) { + await act(async () => { + jest.advanceTimersByTime(1000); + await Promise.resolve(); + }); + } + // Verify if scrollIntoView was called expect(scrollIntoViewMock).toHaveBeenCalledWith({ block: 'nearest', inline: 'nearest', @@ -63,39 +51,25 @@ describe('DropdownMenu', () => { scrollIntoViewMock.mockRestore(); }); - it('should NOT scroll and hit the return statement when input blurs but menu is arguably open', async () => { + it('should NOT scroll into view when navigating without focus', async () => { const { container } = render( - , + + + , ); - const textarea = container.querySelector('textarea'); const scrollIntoViewMock = jest .spyOn(HTMLElement.prototype, 'scrollIntoView') .mockImplementation(jest.fn()); - await act(async () => { - textarea.focus(); - simulateInput(container, '@'); - jest.runAllTimers(); - }); - - await waitFor(() => { - const menuItems = document.querySelectorAll( - '.rc-mentions-dropdown-menu-item', - ); - expect(menuItems.length).toBeGreaterThan(1); - }); - - const menuItems = document.querySelectorAll( - '.rc-mentions-dropdown-menu-item', - ); - - await act(async () => { - fireEvent.mouseEnter(menuItems[1]); + simulateInput(container, '@'); - fireEvent.blur(textarea); - jest.runAllTimers(); - }); + for (let i = 0; i < 10; i++) { + await act(async () => { + jest.advanceTimersByTime(1000); + await Promise.resolve(); + }); + } expect(scrollIntoViewMock).not.toHaveBeenCalled();