From c83e201673bf7eb851f96cba9dfda066fa112c60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E8=89=B3=E5=85=B5?= Date: Thu, 12 Mar 2026 15:57:35 +0800 Subject: [PATCH 1/3] fix: align mentions keyboard selection with select for disabled options --- src/DropdownMenu.tsx | 4 ++- src/Mentions.tsx | 69 ++++++++++++++++++++++++++++++++++++++--- tests/Mentions.spec.tsx | 56 +++++++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 6 deletions(-) diff --git a/src/DropdownMenu.tsx b/src/DropdownMenu.tsx index 8993216..299c3db 100644 --- a/src/DropdownMenu.tsx +++ b/src/DropdownMenu.tsx @@ -66,7 +66,9 @@ function DropdownMenu(props: DropdownMenuProps) { className={className} style={style} onMouseEnter={() => { - setActiveIndex(index); + if (!disabled) { + setActiveIndex(index); + } }} > {label} diff --git a/src/Mentions.tsx b/src/Mentions.tsx index 92efe2f..4e9f38f 100644 --- a/src/Mentions.tsx +++ b/src/Mentions.tsx @@ -273,6 +273,43 @@ const InternalMentions = forwardRef( [getOptions, mergedMeasureText], ); + const getEnabledActiveIndex = React.useCallback( + (index: number, offset: number = 1): number => { + const len = mergedOptions.length; + if (!len) { + return -1; + } + + for (let i = 0; i < len; i += 1) { + const current = (index + i * offset + len) % len; + const option = mergedOptions[current]; + if (!option?.disabled) { + return current; + } + } + + return -1; + }, + [mergedOptions], + ); + + useEffect(() => { + if (!mergedMeasuring) { + return; + } + + const currentOption = mergedOptions[activeIndex]; + if (activeIndex === -1 || currentOption?.disabled) { + setActiveIndex(getEnabledActiveIndex(0)); + } + }, [ + mergedMeasuring, + mergedOptions, + activeIndex, + getEnabledActiveIndex, + setActiveIndex, + ]); + // ============================= Measure ============================== // Mark that we will reset input selection to target position when user select option const onSelectionEffect = useEffectState(); @@ -286,7 +323,7 @@ const InternalMentions = forwardRef( setMeasureText(nextMeasureText); setMeasurePrefix(nextMeasurePrefix); setMeasureLocation(nextMeasureLocation); - setActiveIndex(0); + setActiveIndex(getEnabledActiveIndex(0)); }; const stopMeasure = (callback?: VoidFunction) => { @@ -309,6 +346,9 @@ const InternalMentions = forwardRef( }; const selectOption = (option: OptionProps) => { + if (option?.disabled) { + return; + } const { value: mentionValue = '' } = option; const { text, selectionLocation } = replaceWithMeasure(mergedValue, { measureLocation: mergedMeasureLocation, @@ -343,9 +383,17 @@ const InternalMentions = forwardRef( if (which === KeyCode.UP || which === KeyCode.DOWN) { // Control arrow function const optionLen = mergedOptions.length; + if (!optionLen) { + return; + } const offset = which === KeyCode.UP ? -1 : 1; - const newActiveIndex = (activeIndex + offset + optionLen) % optionLen; - setActiveIndex(newActiveIndex); + const newActiveIndex = getEnabledActiveIndex( + activeIndex + offset, + offset, + ); + if (newActiveIndex !== -1) { + setActiveIndex(newActiveIndex); + } event.preventDefault(); } else if (which === KeyCode.ESC) { stopMeasure(); @@ -361,8 +409,19 @@ const InternalMentions = forwardRef( stopMeasure(); return; } - const option = mergedOptions[activeIndex]; - selectOption(option); + + let targetIndex = activeIndex; + if (targetIndex === -1 || mergedOptions[targetIndex]?.disabled) { + targetIndex = getEnabledActiveIndex(0); + } + + if (targetIndex === -1) { + stopMeasure(); + return; + } + + setActiveIndex(targetIndex); + selectOption(mergedOptions[targetIndex]); } }; diff --git a/tests/Mentions.spec.tsx b/tests/Mentions.spec.tsx index 0c4a505..2957cc2 100644 --- a/tests/Mentions.spec.tsx +++ b/tests/Mentions.spec.tsx @@ -159,6 +159,62 @@ describe('Mentions', () => { }); expect(container.querySelector('textarea').value).toBe('@lig'); }); + + it('should skip disabled option on Enter', () => { + const { container } = renderMentions({ + options: [ + { value: 'bamboo', label: 'Bamboo', disabled: true }, + { value: 'light', label: 'Light' }, + { value: 'cat', label: 'Cat' }, + ], + }); + + simulateInput(container, '@'); + fireEvent.keyDown(container.querySelector('textarea'), { + which: KeyCode.ENTER, + keyCode: KeyCode.ENTER, + }); + expect(container.querySelector('textarea').value).toBe('@light '); + }); + + it('should keep text when all options disabled', () => { + const { container } = renderMentions({ + options: [ + { value: 'bamboo', label: 'Bamboo', disabled: true }, + { value: 'light', label: 'Light', disabled: true }, + { value: 'cat', label: 'Cat', disabled: true }, + ], + }); + + simulateInput(container, '@a'); + fireEvent.keyDown(container.querySelector('textarea'), { + which: KeyCode.ENTER, + keyCode: KeyCode.ENTER, + }); + expect(container.querySelector('textarea').value).toBe('@a'); + }); + + it('arrow keys should skip disabled options', () => { + const { container } = renderMentions({ + options: [ + { value: 'bamboo', label: 'Bamboo' }, + { value: 'light', label: 'Light', disabled: true }, + { value: 'cat', label: 'Cat' }, + ], + }); + + simulateInput(container, '@'); + // DOWN 应该从 bamboo 跳到 cat(跳过 disabled 的 light) + fireEvent.keyDown(container.querySelector('textarea'), { + which: KeyCode.DOWN, + keyCode: KeyCode.DOWN, + }); + fireEvent.keyDown(container.querySelector('textarea'), { + which: KeyCode.ENTER, + keyCode: KeyCode.ENTER, + }); + expect(container.querySelector('textarea').value).toBe('@cat '); + }); }); describe('support children Option', () => { From 81c0a9f8643ab4e662864ba19c4fdd9b675ddbd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E8=89=B3=E5=85=B5?= Date: Thu, 12 Mar 2026 16:26:07 +0800 Subject: [PATCH 2/3] fix: skip disabled options and handle mentions options shrink safely --- src/Mentions.tsx | 11 +++++++---- tests/Mentions.spec.tsx | 27 ++++++++++++++++++++++++++- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/src/Mentions.tsx b/src/Mentions.tsx index 4e9f38f..fa2849f 100644 --- a/src/Mentions.tsx +++ b/src/Mentions.tsx @@ -299,7 +299,7 @@ const InternalMentions = forwardRef( } const currentOption = mergedOptions[activeIndex]; - if (activeIndex === -1 || currentOption?.disabled) { + if (!currentOption || currentOption.disabled) { setActiveIndex(getEnabledActiveIndex(0)); } }, [ @@ -345,8 +345,8 @@ const InternalMentions = forwardRef( triggerChange(nextValue); }; - const selectOption = (option: OptionProps) => { - if (option?.disabled) { + const selectOption = (option?: OptionProps) => { + if (!option || option.disabled) { return; } const { value: mentionValue = '' } = option; @@ -411,7 +411,10 @@ const InternalMentions = forwardRef( } let targetIndex = activeIndex; - if (targetIndex === -1 || mergedOptions[targetIndex]?.disabled) { + if ( + !mergedOptions[targetIndex] || + mergedOptions[targetIndex].disabled + ) { targetIndex = getEnabledActiveIndex(0); } diff --git a/tests/Mentions.spec.tsx b/tests/Mentions.spec.tsx index 2957cc2..075fcb0 100644 --- a/tests/Mentions.spec.tsx +++ b/tests/Mentions.spec.tsx @@ -204,7 +204,6 @@ describe('Mentions', () => { }); simulateInput(container, '@'); - // DOWN 应该从 bamboo 跳到 cat(跳过 disabled 的 light) fireEvent.keyDown(container.querySelector('textarea'), { which: KeyCode.DOWN, keyCode: KeyCode.DOWN, @@ -215,6 +214,32 @@ describe('Mentions', () => { }); expect(container.querySelector('textarea').value).toBe('@cat '); }); + + it('should handle options shrink safely when pressing Enter', () => { + const { container, rerender } = renderMentions(); + + simulateInput(container, '@'); + + fireEvent.keyDown(container.querySelector('textarea'), { + which: KeyCode.DOWN, + keyCode: KeyCode.DOWN, + }); + fireEvent.keyDown(container.querySelector('textarea'), { + which: KeyCode.DOWN, + keyCode: KeyCode.DOWN, + }); + rerender( + createMentions({ + options: [{ value: 'bamboo', label: 'Bamboo' }], + }), + ); + + fireEvent.keyDown(container.querySelector('textarea'), { + which: KeyCode.ENTER, + keyCode: KeyCode.ENTER, + }); + expect(container.querySelector('textarea').value).toBe('@bamboo '); + }); }); describe('support children Option', () => { From 4b547cd518c9a6d402ae3f94732e9bf2c56edee3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E8=89=B3=E5=85=B5?= Date: Thu, 12 Mar 2026 16:56:12 +0800 Subject: [PATCH 3/3] fix: prevent mentions keyboard selection of disabled options and ensure safe handling when options list shrinks --- tests/Mentions.spec.tsx | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/Mentions.spec.tsx b/tests/Mentions.spec.tsx index 075fcb0..73ccfcc 100644 --- a/tests/Mentions.spec.tsx +++ b/tests/Mentions.spec.tsx @@ -240,6 +240,23 @@ describe('Mentions', () => { }); expect(container.querySelector('textarea').value).toBe('@bamboo '); }); + + it('should handle arrow keys when no options', () => { + const { container } = renderMentions({ + options: [], + }); + + simulateInput(container, '@'); + + expect(() => { + fireEvent.keyDown(container.querySelector('textarea'), { + which: KeyCode.DOWN, + keyCode: KeyCode.DOWN, + }); + }).not.toThrow(); + + expect(container.querySelector('textarea').value).toBe('@'); + }); }); describe('support children Option', () => {