diff --git a/packages/x/components/suggestion/__tests__/index.test.tsx b/packages/x/components/suggestion/__tests__/index.test.tsx index fc182a450..2bbed68bd 100644 --- a/packages/x/components/suggestion/__tests__/index.test.tsx +++ b/packages/x/components/suggestion/__tests__/index.test.tsx @@ -2,6 +2,7 @@ import { fireEvent, render, screen } from '@testing-library/react'; import React from 'react'; import mountTest from '../../../tests/shared/mountTest'; import rtlTest from '../../../tests/shared/rtlTest'; +import Sender, { type SenderProps } from '../../sender'; import Suggestion, { type SuggestionProps } from '../index'; describe('Suggestion Component', () => { @@ -84,6 +85,42 @@ describe('Suggestion Component', () => { expect(onOpenChange).toHaveBeenCalledWith(false); }); + it('should not prevent Space input whether the popup is closed or open', () => { + const items = [{ label: 'Suggestion 1', value: 'suggestion1' }]; + const { container } = render(); + const input = container.querySelector('input')!; + + expect(fireEvent.keyDown(input, { key: ' ' })).toBe(true); + + fireEvent.keyDown(input, { key: '/' }); + expect(screen.getByText('Suggestion 1')).toBeInTheDocument(); + expect(fireEvent.keyDown(input, { key: ' ' })).toBe(true); + }); + + it.each<[name: string, senderProps: SenderProps, targetSelector: string]>([ + ['textarea', {}, 'textarea'], + [ + 'input slot', + { slotConfig: [{ type: 'input', key: 'input' }] }, + 'input[data-slot-input="input"]', + ], + [ + 'contenteditable slot', + { slotConfig: [{ type: 'content', key: 'content' }] }, + '[data-slot-key="content"][contenteditable="true"]', + ], + ])('should preserve Space input in a Sender %s', (_, senderProps, targetSelector) => { + const items = [{ label: 'Suggestion 1', value: 'suggestion1' }]; + const { container } = render( + + {({ onKeyDown }) => } + , + ); + const target = container.querySelector(targetSelector)!; + + expect(fireEvent.keyDown(target, { key: ' ' })).toBe(true); + }); + it('open controlled', () => { const items = [ { label: 'Suggestion 1', value: 'suggestion1', icon:
}, diff --git a/packages/x/components/suggestion/__tests__/useActive.test.tsx b/packages/x/components/suggestion/__tests__/useActive.test.tsx index d32c48862..4e45b93ad 100644 --- a/packages/x/components/suggestion/__tests__/useActive.test.tsx +++ b/packages/x/components/suggestion/__tests__/useActive.test.tsx @@ -270,6 +270,70 @@ describe('useActive', () => { const [activePaths] = result.current; expect(activePaths).toEqual([]); }); + + const dispatchSpace = ( + target: HTMLElement, + options: { open?: boolean; altKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } = {}, + ) => { + const items: SuggestionItem[] = [{ label: 'Item 1', value: 'item1' }]; + const stopPropagation = jest.fn(); + const preventDefault = jest.fn(); + const { open = true, ...modifiers } = options; + const { result } = renderHook(() => useActive(items, open, false, jest.fn())); + + act(() => + result.current[1]({ + key: ' ', + target, + stopPropagation, + preventDefault, + ...modifiers, + } as any), + ); + + return { preventDefault, stopPropagation }; + }; + + it.each([true, false])( + 'should stop plain Space propagation from an editable target when open is %s', + (open) => { + const { preventDefault, stopPropagation } = dispatchSpace( + document.createElement('textarea'), + { open }, + ); + + expect(stopPropagation).toHaveBeenCalledTimes(1); + expect(preventDefault).not.toHaveBeenCalled(); + }, + ); + + it('should stop plain Space propagation from a contenteditable target', () => { + const target = document.createElement('div'); + Object.defineProperty(target, 'isContentEditable', { value: true }); + + expect(dispatchSpace(target).stopPropagation).toHaveBeenCalledTimes(1); + }); + + it.each([ + ['Alt', { altKey: true }], + ['Control', { ctrlKey: true }], + ['Meta', { metaKey: true }], + ])('should not stop %s+Space propagation', (_, modifiers) => { + expect( + dispatchSpace(document.createElement('textarea'), modifiers).stopPropagation, + ).not.toHaveBeenCalled(); + }); + + it.each([ + ['button', document.createElement('button')], + ['button input', Object.assign(document.createElement('input'), { type: 'button' })], + ['checkbox input', Object.assign(document.createElement('input'), { type: 'checkbox' })], + ['number input', Object.assign(document.createElement('input'), { type: 'number' })], + ['readonly input', Object.assign(document.createElement('input'), { readOnly: true })], + ['disabled textarea', Object.assign(document.createElement('textarea'), { disabled: true })], + ])('should not stop Space propagation from a %s', (_, target) => { + expect(dispatchSpace(target).stopPropagation).not.toHaveBeenCalled(); + }); }); describe('RTL mode', () => { diff --git a/packages/x/components/suggestion/useActive.ts b/packages/x/components/suggestion/useActive.ts index 60d6c8ee2..69043f9f2 100644 --- a/packages/x/components/suggestion/useActive.ts +++ b/packages/x/components/suggestion/useActive.ts @@ -2,6 +2,24 @@ import { useEvent } from '@rc-component/util'; import React from 'react'; import type { SuggestionItem } from '.'; +const TEXT_INPUT_TYPES = new Set(['email', 'password', 'search', 'tel', 'text', 'url']); + +const isEditableTarget = (target: EventTarget | null) => { + if (!(target instanceof HTMLElement)) { + return false; + } + + if (target instanceof HTMLInputElement) { + return TEXT_INPUT_TYPES.has(target.type) && !target.disabled && !target.readOnly; + } + + if (target instanceof HTMLTextAreaElement) { + return !target.disabled && !target.readOnly; + } + + return target.isContentEditable; +}; + /** * Since Cascader not support ref active, we use `value` to mock the active item. */ @@ -58,6 +76,10 @@ export default function useActive( }; const onKeyDown: React.KeyboardEventHandler = useEvent((e) => { + if (e.key === ' ' && !e.altKey && !e.ctrlKey && !e.metaKey && isEditableTarget(e.target)) { + e.stopPropagation(); + } + if (!open) { return; }