diff --git a/src/components/virtual-input/tests/virtual-input.test.tsx b/src/components/virtual-input/tests/virtual-input.test.tsx index d07d6d701b..92109f9ab1 100644 --- a/src/components/virtual-input/tests/virtual-input.test.tsx +++ b/src/components/virtual-input/tests/virtual-input.test.tsx @@ -828,3 +828,255 @@ describe('VirtualInput', () => { } }) }) + +describe('useClickOutside', () => { + const KeyBoardClassPrefix = 'adm-number-keyboard' + + test('首次点击 VirtualInput 不应同时触发 focus 和 blur', async () => { + const onFocus = jest.fn() + const onBlur = jest.fn() + const user = userEvent.setup() + + const Wrapper = () => { + const [value, setValue] = React.useState('') + return ( + } + /> + ) + } + + render() + const content = document.querySelector(`.${classPrefix}-content`)! + + // 首次点击 VirtualInput content 区域 + await user.click(content) + + // 应该只触发 focus,不触发 blur + expect(onFocus).toBeCalledTimes(1) + expect(onBlur).toBeCalledTimes(0) + + // 键盘应该可见 + expect( + document.querySelector(`.${KeyBoardClassPrefix}-popup`) + ).toBeVisible() + }) + + test('点击外部应触发 blur', async () => { + const onFocus = jest.fn() + const onBlur = jest.fn() + const user = userEvent.setup() + + const Wrapper = () => { + const [value, setValue] = React.useState('') + return ( +
+ } + /> + +
+ ) + } + + render() + const content = document.querySelector(`.${classPrefix}-content`)! + + // 先 focus + await user.click(content) + expect(onFocus).toBeCalledTimes(1) + + // 点击外部 + await user.click(screen.getByTestId('outside')) + expect(onBlur).toBeCalledTimes(1) + }) + + test('handler 更新后点击外部应使用最新的 handler', async () => { + const onBlur1 = jest.fn() + const onBlur2 = jest.fn() + const user = userEvent.setup() + + const Wrapper = () => { + const [value, setValue] = React.useState('') + const [useSecond, setUseSecond] = React.useState(false) + return ( +
+ { + // focus 时切换到第二个 handler + setUseSecond(true) + }} + onBlur={useSecond ? onBlur2 : onBlur1} + keyboard={} + /> + +
+ ) + } + + render() + const content = document.querySelector(`.${classPrefix}-content`)! + + // focus,同时触发 handler 切换 + await user.click(content) + + // 点击外部,应调用最新的 onBlur2(而非旧的 onBlur1) + await user.click(screen.getByTestId('outside')) + + expect(onBlur1).toBeCalledTimes(0) + expect(onBlur2).toBeCalledTimes(1) + }) + + test('多次点击 VirtualInput 不应重复触发 focus', async () => { + const onFocus = jest.fn() + const onBlur = jest.fn() + const user = userEvent.setup() + + const Wrapper = () => { + const [value, setValue] = React.useState('') + return ( + } + /> + ) + } + + render() + const content = document.querySelector(`.${classPrefix}-content`)! + + // 连续点击多次 + await user.click(content) + await user.click(content) + await user.click(content) + + // focus 只触发一次,blur 不触发 + expect(onFocus).toBeCalledTimes(1) + expect(onBlur).toBeCalledTimes(0) + }) + + test('外部元素 focusin 应触发 VirtualInput blur', async () => { + const onFocus = jest.fn() + const onBlur = jest.fn() + const user = userEvent.setup() + + const Wrapper = () => { + const [value, setValue] = React.useState('') + return ( +
+ } + /> + +
+ ) + } + + render() + const content = document.querySelector(`.${classPrefix}-content`)! + const outsideInput = screen.getByTestId('outside-input') + + // 先 focus VirtualInput + await user.click(content) + expect(onFocus).toBeCalledTimes(1) + expect(onBlur).toBeCalledTimes(0) + + // 外部 input 获得焦点,触发 focusin 事件 + outsideInput.focus() + await waitFor(() => { + expect(onBlur).toBeCalledTimes(1) + }) + }) + + test('VirtualInput 内部元素 focusin 不应触发 blur', async () => { + const onFocus = jest.fn() + const onBlur = jest.fn() + const user = userEvent.setup() + + const Wrapper = () => { + const [value, setValue] = React.useState('') + return ( + } + /> + ) + } + + render() + const content = document.querySelector(`.${classPrefix}-content`)! + + // focus VirtualInput + await user.click(content) + expect(onFocus).toBeCalledTimes(1) + + // 模拟内部元素获得焦点 + const focusinEvent = new FocusEvent('focusin', { + bubbles: true, + cancelable: true, + }) + content.dispatchEvent(focusinEvent) + + // blur 不应被触发 + expect(onBlur).toBeCalledTimes(0) + }) + + test('focusin 事件应使用最新的 handler', async () => { + const onBlur1 = jest.fn() + const onBlur2 = jest.fn() + const user = userEvent.setup() + + const Wrapper = () => { + const [value, setValue] = React.useState('') + const [useSecond, setUseSecond] = React.useState(false) + return ( +
+ { + setUseSecond(true) + }} + onBlur={useSecond ? onBlur2 : onBlur1} + keyboard={} + /> + +
+ ) + } + + render() + const content = document.querySelector(`.${classPrefix}-content`)! + const outsideInput = screen.getByTestId('outside-input') + + // focus,同时触发 handler 切换 + await user.click(content) + + // 外部 input 获得焦点,应调用最新的 onBlur2 + outsideInput.focus() + await waitFor(() => { + expect(onBlur1).toBeCalledTimes(0) + expect(onBlur2).toBeCalledTimes(1) + }) + }) +}) diff --git a/src/components/virtual-input/use-click-outside.tsx b/src/components/virtual-input/use-click-outside.tsx index 248f13f9c5..221a12c43c 100644 --- a/src/components/virtual-input/use-click-outside.tsx +++ b/src/components/virtual-input/use-click-outside.tsx @@ -1,19 +1,21 @@ +import { useEvent } from 'rc-util' import { useEffect } from 'react' // 监听点击组件外部的事件 function useClickOutside( handler: (event: MouseEvent) => void, ref: React.RefObject, - hasKeyboardProps: boolean = false + hasKeyboardProps = false ) { + const stableHandler = useEvent(handler) + useEffect(() => { function handleClick(event: MouseEvent) { if (!ref.current || ref.current.contains(event.target as Node)) { return } - handler(event) + stableHandler(event) // 使用 ref 中的 handler } - // 向前兼容逻辑: // 1. 对于有键盘属性的 VirtualInput,在捕获阶段监听: // 这是为了确保在事件被阻止传播之前触发。比如输入框中的单个数字 click 事件会 stopPropagation, 但这里依然能捕获到 @@ -21,20 +23,23 @@ function useClickOutside( // 这种情况通常是 VirtualInput + NumberKeyboard 为兄弟关系,在以前版本中点击 NumberKeyboard **不会**触发 VirtualInput 的 blur 事件 // 原先原理:通过 NumberKeyboard 内部 onMouseDown 时 preventDefault 阻止的 VirtualInput 内原生的 blur 事件 // 新的原理:NumberKeyboard 的 Popup 默认会 stopPropagation click, 这里在冒泡阶段监听不到,不会调用 VirtualInput 的 onBlur 回调(非原生事件)。 - document.addEventListener( - 'click', - handleClick, - hasKeyboardProps ? true : false - ) + // 安卓长按 native input 时不会触发 click 事件,通过监听 focusin 补充处理: + // 长按后系统键盘弹出,native input 获得焦点并冒泡 focusin 到 document,以此触发 blur + function handleFocusIn(event: FocusEvent) { + if (!ref.current || ref.current.contains(event.target as Node)) { + return + } + stableHandler(event as unknown as MouseEvent) + } + + document.addEventListener('click', handleClick, hasKeyboardProps) + document.addEventListener('focusin', handleFocusIn, hasKeyboardProps) return () => { - document.removeEventListener( - 'click', - handleClick, - hasKeyboardProps ? true : false - ) + document.removeEventListener('click', handleClick, hasKeyboardProps) + document.removeEventListener('focusin', handleFocusIn, hasKeyboardProps) } - }, [handler, ref]) + }, [ref]) // 只依赖 ref,不依赖 handler } export default useClickOutside