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