Skip to content

Commit ec60ea9

Browse files
authored
fix: avoid selecting disabled options via keyboard in Mentions (#308)
* fix: align mentions keyboard selection with select for disabled options * fix: skip disabled options and handle mentions options shrink safely * fix: prevent mentions keyboard selection of disabled options and ensure safe handling when options list shrinks
1 parent 6f774ea commit ec60ea9

3 files changed

Lines changed: 169 additions & 7 deletions

File tree

src/DropdownMenu.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,9 @@ function DropdownMenu(props: DropdownMenuProps) {
6666
className={className}
6767
style={style}
6868
onMouseEnter={() => {
69-
setActiveIndex(index);
69+
if (!disabled) {
70+
setActiveIndex(index);
71+
}
7072
}}
7173
>
7274
{label}

src/Mentions.tsx

Lines changed: 68 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,43 @@ const InternalMentions = forwardRef<MentionsRef, InternalMentionsProps>(
273273
[getOptions, mergedMeasureText],
274274
);
275275

276+
const getEnabledActiveIndex = React.useCallback(
277+
(index: number, offset: number = 1): number => {
278+
const len = mergedOptions.length;
279+
if (!len) {
280+
return -1;
281+
}
282+
283+
for (let i = 0; i < len; i += 1) {
284+
const current = (index + i * offset + len) % len;
285+
const option = mergedOptions[current];
286+
if (!option?.disabled) {
287+
return current;
288+
}
289+
}
290+
291+
return -1;
292+
},
293+
[mergedOptions],
294+
);
295+
296+
useEffect(() => {
297+
if (!mergedMeasuring) {
298+
return;
299+
}
300+
301+
const currentOption = mergedOptions[activeIndex];
302+
if (!currentOption || currentOption.disabled) {
303+
setActiveIndex(getEnabledActiveIndex(0));
304+
}
305+
}, [
306+
mergedMeasuring,
307+
mergedOptions,
308+
activeIndex,
309+
getEnabledActiveIndex,
310+
setActiveIndex,
311+
]);
312+
276313
// ============================= Measure ==============================
277314
// Mark that we will reset input selection to target position when user select option
278315
const onSelectionEffect = useEffectState();
@@ -286,7 +323,7 @@ const InternalMentions = forwardRef<MentionsRef, InternalMentionsProps>(
286323
setMeasureText(nextMeasureText);
287324
setMeasurePrefix(nextMeasurePrefix);
288325
setMeasureLocation(nextMeasureLocation);
289-
setActiveIndex(0);
326+
setActiveIndex(getEnabledActiveIndex(0));
290327
};
291328

292329
const stopMeasure = (callback?: VoidFunction) => {
@@ -308,7 +345,10 @@ const InternalMentions = forwardRef<MentionsRef, InternalMentionsProps>(
308345
triggerChange(nextValue);
309346
};
310347

311-
const selectOption = (option: OptionProps) => {
348+
const selectOption = (option?: OptionProps) => {
349+
if (!option || option.disabled) {
350+
return;
351+
}
312352
const { value: mentionValue = '' } = option;
313353
const { text, selectionLocation } = replaceWithMeasure(mergedValue, {
314354
measureLocation: mergedMeasureLocation,
@@ -343,9 +383,17 @@ const InternalMentions = forwardRef<MentionsRef, InternalMentionsProps>(
343383
if (which === KeyCode.UP || which === KeyCode.DOWN) {
344384
// Control arrow function
345385
const optionLen = mergedOptions.length;
386+
if (!optionLen) {
387+
return;
388+
}
346389
const offset = which === KeyCode.UP ? -1 : 1;
347-
const newActiveIndex = (activeIndex + offset + optionLen) % optionLen;
348-
setActiveIndex(newActiveIndex);
390+
const newActiveIndex = getEnabledActiveIndex(
391+
activeIndex + offset,
392+
offset,
393+
);
394+
if (newActiveIndex !== -1) {
395+
setActiveIndex(newActiveIndex);
396+
}
349397
event.preventDefault();
350398
} else if (which === KeyCode.ESC) {
351399
stopMeasure();
@@ -361,8 +409,22 @@ const InternalMentions = forwardRef<MentionsRef, InternalMentionsProps>(
361409
stopMeasure();
362410
return;
363411
}
364-
const option = mergedOptions[activeIndex];
365-
selectOption(option);
412+
413+
let targetIndex = activeIndex;
414+
if (
415+
!mergedOptions[targetIndex] ||
416+
mergedOptions[targetIndex].disabled
417+
) {
418+
targetIndex = getEnabledActiveIndex(0);
419+
}
420+
421+
if (targetIndex === -1) {
422+
stopMeasure();
423+
return;
424+
}
425+
426+
setActiveIndex(targetIndex);
427+
selectOption(mergedOptions[targetIndex]);
366428
}
367429
};
368430

tests/Mentions.spec.tsx

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,104 @@ describe('Mentions', () => {
159159
});
160160
expect(container.querySelector('textarea').value).toBe('@lig');
161161
});
162+
163+
it('should skip disabled option on Enter', () => {
164+
const { container } = renderMentions({
165+
options: [
166+
{ value: 'bamboo', label: 'Bamboo', disabled: true },
167+
{ value: 'light', label: 'Light' },
168+
{ value: 'cat', label: 'Cat' },
169+
],
170+
});
171+
172+
simulateInput(container, '@');
173+
fireEvent.keyDown(container.querySelector('textarea'), {
174+
which: KeyCode.ENTER,
175+
keyCode: KeyCode.ENTER,
176+
});
177+
expect(container.querySelector('textarea').value).toBe('@light ');
178+
});
179+
180+
it('should keep text when all options disabled', () => {
181+
const { container } = renderMentions({
182+
options: [
183+
{ value: 'bamboo', label: 'Bamboo', disabled: true },
184+
{ value: 'light', label: 'Light', disabled: true },
185+
{ value: 'cat', label: 'Cat', disabled: true },
186+
],
187+
});
188+
189+
simulateInput(container, '@a');
190+
fireEvent.keyDown(container.querySelector('textarea'), {
191+
which: KeyCode.ENTER,
192+
keyCode: KeyCode.ENTER,
193+
});
194+
expect(container.querySelector('textarea').value).toBe('@a');
195+
});
196+
197+
it('arrow keys should skip disabled options', () => {
198+
const { container } = renderMentions({
199+
options: [
200+
{ value: 'bamboo', label: 'Bamboo' },
201+
{ value: 'light', label: 'Light', disabled: true },
202+
{ value: 'cat', label: 'Cat' },
203+
],
204+
});
205+
206+
simulateInput(container, '@');
207+
fireEvent.keyDown(container.querySelector('textarea'), {
208+
which: KeyCode.DOWN,
209+
keyCode: KeyCode.DOWN,
210+
});
211+
fireEvent.keyDown(container.querySelector('textarea'), {
212+
which: KeyCode.ENTER,
213+
keyCode: KeyCode.ENTER,
214+
});
215+
expect(container.querySelector('textarea').value).toBe('@cat ');
216+
});
217+
218+
it('should handle options shrink safely when pressing Enter', () => {
219+
const { container, rerender } = renderMentions();
220+
221+
simulateInput(container, '@');
222+
223+
fireEvent.keyDown(container.querySelector('textarea'), {
224+
which: KeyCode.DOWN,
225+
keyCode: KeyCode.DOWN,
226+
});
227+
fireEvent.keyDown(container.querySelector('textarea'), {
228+
which: KeyCode.DOWN,
229+
keyCode: KeyCode.DOWN,
230+
});
231+
rerender(
232+
createMentions({
233+
options: [{ value: 'bamboo', label: 'Bamboo' }],
234+
}),
235+
);
236+
237+
fireEvent.keyDown(container.querySelector('textarea'), {
238+
which: KeyCode.ENTER,
239+
keyCode: KeyCode.ENTER,
240+
});
241+
expect(container.querySelector('textarea').value).toBe('@bamboo ');
242+
});
243+
244+
it('should handle arrow keys when no options', () => {
245+
const { container } = renderMentions({
246+
options: [],
247+
});
248+
249+
simulateInput(container, '@');
250+
251+
expect(() => {
252+
fireEvent.keyDown(container.querySelector('textarea'), {
253+
which: KeyCode.DOWN,
254+
keyCode: KeyCode.DOWN,
255+
});
256+
}).not.toThrow();
257+
258+
expect(container.querySelector('textarea').value).toBe('@');
259+
});
162260
});
163261

164262
describe('support children Option', () => {

0 commit comments

Comments
 (0)