Skip to content
252 changes: 252 additions & 0 deletions src/components/virtual-input/tests/virtual-input.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<VirtualInput
value={value}
onChange={setValue}
onFocus={onFocus}
onBlur={onBlur}
keyboard={<NumberKeyboard />}
/>
)
}

render(<Wrapper />)
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 (
<div>
<VirtualInput
value={value}
onChange={setValue}
onFocus={onFocus}
onBlur={onBlur}
keyboard={<NumberKeyboard />}
/>
<button data-testid='outside'>outside</button>
</div>
)
}

render(<Wrapper />)
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 (
<div>
<VirtualInput
value={value}
onChange={setValue}
onFocus={() => {
// focus 时切换到第二个 handler
setUseSecond(true)
}}
onBlur={useSecond ? onBlur2 : onBlur1}
keyboard={<NumberKeyboard />}
/>
<button data-testid='outside'>outside</button>
</div>
)
}

render(<Wrapper />)
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 (
<VirtualInput
value={value}
onChange={setValue}
onFocus={onFocus}
onBlur={onBlur}
keyboard={<NumberKeyboard />}
/>
)
}

render(<Wrapper />)
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 (
<div>
<VirtualInput
value={value}
onChange={setValue}
onFocus={onFocus}
onBlur={onBlur}
keyboard={<NumberKeyboard />}
/>
<input data-testid='outside-input' />
</div>
)
}

render(<Wrapper />)
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 (
<VirtualInput
value={value}
onChange={setValue}
onFocus={onFocus}
onBlur={onBlur}
keyboard={<NumberKeyboard />}
/>
)
}

render(<Wrapper />)
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 (
<div>
<VirtualInput
value={value}
onChange={setValue}
onFocus={() => {
setUseSecond(true)
}}
onBlur={useSecond ? onBlur2 : onBlur1}
keyboard={<NumberKeyboard />}
/>
<input data-testid='outside-input' />
</div>
)
}

render(<Wrapper />)
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)
})
})
})
33 changes: 19 additions & 14 deletions src/components/virtual-input/use-click-outside.tsx
Original file line number Diff line number Diff line change
@@ -1,40 +1,45 @@
import { useEvent } from 'rc-util'
import { useEffect } from 'react'

// 监听点击组件外部的事件
function useClickOutside(
handler: (event: MouseEvent) => void,
ref: React.RefObject<HTMLElement>,
Comment on lines 6 to 7
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 验证 useClickOutside 的调用点是否依赖 MouseEvent 专有字段
rg -nP "useClickOutside\\s*\\(" src/components -C3
rg -nP "\\bevent\\.(button|clientX|clientY|pageX|pageY|screenX|screenY)\\b" src/components/virtual-input -C2

Repository: ant-design/ant-design-mobile

Length of output: 1080


🏁 Script executed:

cat -n src/components/virtual-input/use-click-outside.tsx

Repository: ant-design/ant-design-mobile

Length of output: 2090


🏁 Script executed:

sed -n '145,155p' src/components/virtual-input/virtual-input.tsx

Repository: ant-design/ant-design-mobile

Length of output: 220


🏁 Script executed:

rg "useClickOutside" src/components --type ts --type tsx

Repository: ant-design/ant-design-mobile

Length of output: 99


🏁 Script executed:

rg "useClickOutside" src/components

Repository: ant-design/ant-design-mobile

Length of output: 489


🏁 Script executed:

sed -n '/describe.*useClickOutside/,/^describe/p' src/components/virtual-input/tests/virtual-input.test.tsx | head -50

Repository: ant-design/ant-design-mobile

Length of output: 1342


改进 useClickOutside 的事件类型处理,消除类型不安全的强制转换。

第 33 行的双重断言 event as unknown as MouseEvent 存在类型安全风险。handleFocusIn 接收 FocusEvent 但被强制转为 MouseEvent,会掩盖实际的事件类型。建议将 handler 的类型改为联合类型 (event: MouseEvent | FocusEvent) => void,去掉强制转换。

建议修改
 function useClickOutside(
-  handler: (event: MouseEvent) => void,
+  handler: (event: MouseEvent | FocusEvent) => void,
   ref: React.RefObject<HTMLElement>,
   hasKeyboardProps = false
 ) {
     function handleFocusIn(event: FocusEvent) {
       if (!ref.current || ref.current.contains(event.target as Node)) {
         return
       }
-      stableHandler(event as unknown as MouseEvent)
+      stableHandler(event)
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
handler: (event: MouseEvent) => void,
ref: React.RefObject<HTMLElement>,
handler: (event: MouseEvent | FocusEvent) => void,
ref: React.RefObject<HTMLElement>,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/virtual-input/use-click-outside.tsx` around lines 6 - 7, The
handler passed into useClickOutside is currently typed as (event: MouseEvent) =>
void while handleFocusIn receives a FocusEvent and is being force-cast (event as
unknown as MouseEvent); change the handler signature to (event: MouseEvent |
FocusEvent) => void in useClickOutside and update any call sites (e.g., where
handleFocusIn is attached) to accept the union type, then remove the unsafe
double-cast in handleFocusIn so you pass the actual FocusEvent through without
coercion.

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, 但这里依然能捕获到
// 2. 对于无键盘属性的 VirtualInput 组件,在冒泡阶段监听:
// 这种情况通常是 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
Comment on lines +36 to +42
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# 验证 effect 内是否使用 hasKeyboardProps 且依赖数组未包含它
rg -n "addEventListener\('click'.*hasKeyboardProps|removeEventListener\('click'.*hasKeyboardProps|\}, \[ref\]" src/components/virtual-input/use-click-outside.tsx

Repository: ant-design/ant-design-mobile

Length of output: 260


🏁 Script executed:

cat -n src/components/virtual-input/use-click-outside.tsx | head -60

Repository: ant-design/ant-design-mobile

Length of output: 2054


hasKeyboardProps 添加到 effect 依赖数组,避免监听阶段失效。

第 36 行与第 39 行使用 hasKeyboardProps 控制事件监听的阶段(捕获阶段或冒泡阶段),但第 42 行的依赖数组中未包含它。当 hasKeyboardProps 在组件生命周期内变化时,监听器不会按新的阶段重绑,导致监听行为与期望不符。

建议修改
-  }, [ref]) // 只依赖 ref,不依赖 handler
+  }, [ref, hasKeyboardProps]) // 当 hasKeyboardProps 变化时重新绑定监听
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
document.addEventListener('click', handleClick, hasKeyboardProps)
document.addEventListener('focusin', handleFocusIn)
return () => {
document.removeEventListener(
'click',
handleClick,
hasKeyboardProps ? true : false
)
document.removeEventListener('click', handleClick, hasKeyboardProps)
document.removeEventListener('focusin', handleFocusIn)
}
}, [handler, ref])
}, [ref]) // 只依赖 ref,不依赖 handler
document.addEventListener('click', handleClick, hasKeyboardProps)
document.addEventListener('focusin', handleFocusIn)
return () => {
document.removeEventListener('click', handleClick, hasKeyboardProps)
document.removeEventListener('focusin', handleFocusIn)
}
}, [ref, hasKeyboardProps]) // 当 hasKeyboardProps 变化时重新绑定监听
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/virtual-input/use-click-outside.tsx` around lines 36 - 42,
Effect currently binds and removes the click listener using hasKeyboardProps but
does not list hasKeyboardProps in the useEffect dependency array, so when
hasKeyboardProps changes the listener's capture/bubble phase isn't updated;
update the dependency array to include hasKeyboardProps (alongside ref) so the
effect re-runs and rebinds/remove listeners with the correct phase, ensuring the
addEventListener/removeEventListener calls that reference handleClick and
hasKeyboardProps are always paired using the same hasKeyboardProps value.

}
Comment on lines 5 to 43
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The type cast event as unknown as MouseEvent for a FocusEvent is unsafe and suggests a type mismatch. Since the handler's implementation in virtual-input.tsx doesn't utilize the event object, it would be safer and clearer to modify the hook's signature to accept a handler with no arguments, such as handler: () => void. This change would eliminate the need for the unsafe type cast and make the hook's intended use more explicit.

function useClickOutside(
  handler: () => void,
  ref: React.RefObject<HTMLElement>,
  hasKeyboardProps = false
) {
  const stableHandler = useEvent(handler)

  useEffect(() => {
    function handleClick(event: MouseEvent) {
      if (!ref.current || ref.current.contains(event.target as Node)) {
        return
      }
      stableHandler() // 使用 ref 中的 handler
    }
    // 向前兼容逻辑:
    // 1. 对于有键盘属性的 VirtualInput,在捕获阶段监听:
    //      这是为了确保在事件被阻止传播之前触发。比如输入框中的单个数字 click 事件会 stopPropagation, 但这里依然能捕获到
    // 2. 对于无键盘属性的 VirtualInput 组件,在冒泡阶段监听:
    //      这种情况通常是 VirtualInput + NumberKeyboard 为兄弟关系,在以前版本中点击 NumberKeyboard **不会**触发 VirtualInput 的 blur 事件
    //      原先原理:通过 NumberKeyboard 内部 onMouseDown 时 preventDefault 阻止的 VirtualInput 内原生的 blur 事件
    //      新的原理:NumberKeyboard 的 Popup 默认会 stopPropagation click, 这里在冒泡阶段监听不到,不会调用 VirtualInput 的 onBlur 回调(非原生事件)。

    // 安卓长按 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()
    }

    document.addEventListener('click', handleClick, hasKeyboardProps)
    document.addEventListener('focusin', handleFocusIn)
    return () => {
      document.removeEventListener('click', handleClick, hasKeyboardProps)
      document.removeEventListener('focusin', handleFocusIn)
    }
  }, [ref]) // 只依赖 ref,不依赖 handler
}


export default useClickOutside
Loading