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..fa2849f 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 (!currentOption || 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) => { @@ -308,7 +345,10 @@ const InternalMentions = forwardRef( triggerChange(nextValue); }; - const selectOption = (option: OptionProps) => { + const selectOption = (option?: OptionProps) => { + if (!option || 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,22 @@ const InternalMentions = forwardRef( stopMeasure(); return; } - const option = mergedOptions[activeIndex]; - selectOption(option); + + let targetIndex = activeIndex; + if ( + !mergedOptions[targetIndex] || + 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..73ccfcc 100644 --- a/tests/Mentions.spec.tsx +++ b/tests/Mentions.spec.tsx @@ -159,6 +159,104 @@ 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, '@'); + 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 '); + }); + + 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 '); + }); + + 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', () => {