Skip to content

Commit 43339eb

Browse files
committed
fix(frontend): commit Select choice on pointerdown to avoid portal race
When the dropdown is rendered through createPortal under document.body, the document-level outside-click listener and the option's React onClick can race in certain DOM/Radix contexts: the outside-click handler fires on mousedown bubble, which can unmount the portal before the option's click ever reaches React, leaving the user unable to pick a value. Switch the listener to pointerdown (rejecting only targets outside both the trigger and the portaled dropdown) and commit selection from the option's onPointerDown handler with preventDefault. The onClick handler stays as a keyboard-triggered fallback for Enter/Space.
1 parent e797dbe commit 43339eb

1 file changed

Lines changed: 27 additions & 7 deletions

File tree

frontend/src/components/ui/select.tsx

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,13 @@ export function Select({
7171
useEffect(() => {
7272
if (!open) return
7373

74-
const handlePointerDown = (event: MouseEvent) => {
75-
const target = event.target as Node
74+
// 关闭仅在「点击 trigger 与 dropdown 之外」触发。注意 dropdown 通过 createPortal
75+
// 渲染在 document.body 下,与 trigger 不在同一 DOM 子树,必须按 ref 直接判断。
76+
// 用 pointerdown 而非 mousedown,能同时覆盖鼠标 / 触屏 / 笔,且对路径上的 React
77+
// 合成事件 stopPropagation 不敏感(native 监听拿到的总是真实 target)。
78+
const handlePointerDown = (event: PointerEvent) => {
79+
const target = event.target as Node | null
80+
if (!target) return
7681
if (triggerRef.current?.contains(target)) return
7782
if (dropdownRef.current?.contains(target)) return
7883
setOpen(false)
@@ -86,19 +91,27 @@ export function Select({
8691

8792
const handleReposition = () => computePosition()
8893

89-
document.addEventListener('mousedown', handlePointerDown)
94+
document.addEventListener('pointerdown', handlePointerDown)
9095
document.addEventListener('keydown', handleEscape)
9196
window.addEventListener('resize', handleReposition)
9297
window.addEventListener('scroll', handleReposition, true)
9398

9499
return () => {
95-
document.removeEventListener('mousedown', handlePointerDown)
100+
document.removeEventListener('pointerdown', handlePointerDown)
96101
document.removeEventListener('keydown', handleEscape)
97102
window.removeEventListener('resize', handleReposition)
98103
window.removeEventListener('scroll', handleReposition, true)
99104
}
100105
}, [open, computePosition])
101106

107+
const handleSelect = useCallback(
108+
(next: string) => {
109+
onValueChange(next)
110+
setOpen(false)
111+
},
112+
[onValueChange]
113+
)
114+
102115
return (
103116
<div className={cn('relative w-full', className)}>
104117
<button
@@ -163,10 +176,17 @@ export function Select({
163176
? 'bg-primary/10 text-primary'
164177
: 'text-foreground hover:bg-accent/70 hover:text-accent-foreground'
165178
)}
166-
onClick={() => {
167-
onValueChange(option.value)
168-
setOpen(false)
179+
// 用 onPointerDown 在 target 阶段直接 commit 选择:
180+
// 1. 早于 document 的 outside-pointerdown handler,避免 portal 边界
181+
// 场景下 dropdown 被先关掉、click 永远收不到的竞态;
182+
// 2. preventDefault 阻止 button 的默认 focus 转移,下拉关闭时焦点自然
183+
// 回到 trigger,不会跳到无关元素。
184+
onPointerDown={(event) => {
185+
event.preventDefault()
186+
handleSelect(option.value)
169187
}}
188+
// onClick 兜底:键盘 Enter / Space 触发的合成 click 没有 pointerdown。
189+
onClick={() => handleSelect(option.value)}
170190
>
171191
<span className="truncate">{option.label}</span>
172192
<Check className={cn('size-4 shrink-0', isSelected ? 'opacity-100' : 'opacity-0')} />

0 commit comments

Comments
 (0)