From 2b5ae130acc6a8e6080035ec07d9be3dcdc243db Mon Sep 17 00:00:00 2001 From: Rylan Date: Tue, 2 Dec 2025 19:22:28 +0800 Subject: [PATCH 1/5] fix(Input): avoid blur and focus when click icon --- packages/components/input/Input.tsx | 47 ++++++++++++++++++++--------- 1 file changed, 33 insertions(+), 14 deletions(-) diff --git a/packages/components/input/Input.tsx b/packages/components/input/Input.tsx index 40d5c99d91..ea6a3d0610 100644 --- a/packages/components/input/Input.tsx +++ b/packages/components/input/Input.tsx @@ -1,24 +1,26 @@ -import React, { useState, useRef, useImperativeHandle, useEffect } from 'react'; -import classNames from 'classnames'; +import React, { useEffect, useImperativeHandle, useRef, useState } from 'react'; import { BrowseIcon as TdBrowseIcon, BrowseOffIcon as TdBrowseOffIcon, CloseCircleFilledIcon as TdCloseCircleFilledIcon, } from 'tdesign-icons-react'; +import classNames from 'classnames'; import { isFunction } from 'lodash-es'; -import useLayoutEffect from '../hooks/useLayoutEffect'; + import forwardRefWithStatics from '../_util/forwardRefWithStatics'; +import parseTNode from '../_util/parseTNode'; import useConfig from '../hooks/useConfig'; -import useGlobalIcon from '../hooks/useGlobalIcon'; -import { TdInputProps } from './type'; -import { StyledProps, TNode, TElement } from '../common'; -import InputGroup from './InputGroup'; import useControlled from '../hooks/useControlled'; +import useDefaultProps from '../hooks/useDefaultProps'; +import useGlobalIcon from '../hooks/useGlobalIcon'; +import useLayoutEffect from '../hooks/useLayoutEffect'; import { useLocaleReceiver } from '../locale/LocalReceiver'; import { inputDefaultProps } from './defaultProps'; -import parseTNode from '../_util/parseTNode'; +import InputGroup from './InputGroup'; import useLengthLimit from './useLengthLimit'; -import useDefaultProps from '../hooks/useDefaultProps'; + +import type { StyledProps, TElement, TNode } from '../common'; +import type { TdInputProps } from './type'; export interface InputProps extends TdInputProps, StyledProps { showInput?: boolean; // 控制透传readonly同时是否展示input 默认保留 因为正常Input需要撑开宽度 @@ -114,15 +116,17 @@ const Input = forwardRefWithStatics( }); const { classPrefix, input: inputConfig } = useConfig(); + const composingRef = useRef(false); const inputRef: React.RefObject = useRef(null); // inputPreRef 用于预存输入框宽度,应用在 auto width 模式中 const inputPreRef: React.RefObject = useRef(null); const wrapperRef: React.RefObject = useRef(null); + const isClickingIconRef = useRef(false); + const [isHover, toggleIsHover] = useState(false); const [isFocused, toggleIsFocused] = useState(false); const [renderType, setRenderType] = useState(type); - const [composingValue, setComposingValue] = useState(''); // 组件内部 input 原生控件是否处于 readonly 状态,当整个组件 readonly 时,或者处于不可输入时 @@ -144,12 +148,19 @@ const Input = forwardRefWithStatics( /> ); if (type === 'password' && typeof suffixIcon === 'undefined') { - if (renderType === 'password') { + const PASSWORD_ICON_MAP = { + password: BrowseOffIcon, + text: BrowseIcon, + }; + const PasswordIcon = PASSWORD_ICON_MAP[renderType]; + if (PasswordIcon) { suffixIconNew = ( - + ); - } else if (renderType === 'text') { - suffixIconNew = ; } } @@ -297,6 +308,7 @@ const Input = forwardRefWithStatics( requestAnimationFrame(() => { inputEl?.setSelectionRange(cursorPosition, cursorPosition); + isClickingIconRef.current = false; }); } @@ -322,10 +334,15 @@ const Input = forwardRefWithStatics( e.stopPropagation(); // 兼容React16 e.nativeEvent.stopImmediatePropagation(); + isClickingIconRef.current = true; } function handleClear(e: React.MouseEvent) { + isClickingIconRef.current = true; onChange?.('', { e, trigger: 'clear' }); onClear?.({ e }); + requestAnimationFrame(() => { + isClickingIconRef.current = false; + }); } function handleKeyDown(e: React.KeyboardEvent) { const { @@ -366,6 +383,7 @@ const Input = forwardRefWithStatics( } function handleFocus(e: React.FocusEvent) { + if (isClickingIconRef.current) return; const { currentTarget: { value }, } = e; @@ -375,6 +393,7 @@ const Input = forwardRefWithStatics( } function handleBlur(e: React.FocusEvent) { + if (isClickingIconRef.current) return; const { currentTarget: { value }, } = e; From 8cde11193ee0ecaeaf810fa8005c98c478c42a3c Mon Sep 17 00:00:00 2001 From: Rylan Date: Tue, 2 Dec 2025 19:45:50 +0800 Subject: [PATCH 2/5] fix: add timeout for blur test --- packages/components/input/__tests__/input.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/components/input/__tests__/input.test.tsx b/packages/components/input/__tests__/input.test.tsx index 677f9c1f01..572142b38b 100644 --- a/packages/components/input/__tests__/input.test.tsx +++ b/packages/components/input/__tests__/input.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { render, fireEvent, vi, act } from '@test/utils'; +import { render, fireEvent, vi, act, mockTimeout } from '@test/utils'; import userEvent from '@testing-library/user-event'; import Input from '../Input'; @@ -47,6 +47,7 @@ describe('Input 组件测试', () => { fireEvent.mouseUp(clearIcon); fireEvent.click(clearIcon); expect(blurFn).toBeCalledTimes(0); + await mockTimeout(() => true); fireEvent.blur(InputDom); expect(blurFn).toBeCalledTimes(1); }); From 6775691f1797c30afcc3544eef078c24aaf0e71c Mon Sep 17 00:00:00 2001 From: Rylan Date: Tue, 2 Dec 2025 20:05:52 +0800 Subject: [PATCH 3/5] chore: remove redundant logic --- packages/components/input/Input.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/components/input/Input.tsx b/packages/components/input/Input.tsx index ea6a3d0610..2b01683aeb 100644 --- a/packages/components/input/Input.tsx +++ b/packages/components/input/Input.tsx @@ -337,7 +337,6 @@ const Input = forwardRefWithStatics( isClickingIconRef.current = true; } function handleClear(e: React.MouseEvent) { - isClickingIconRef.current = true; onChange?.('', { e, trigger: 'clear' }); onClear?.({ e }); requestAnimationFrame(() => { From dc11c706175b9ac21b2382bcb1d47638bf93316b Mon Sep 17 00:00:00 2001 From: Rylan Date: Tue, 2 Dec 2025 22:20:16 +0800 Subject: [PATCH 4/5] refactor: bind `onMouseDown` to the outer div --- packages/components/input/Input.tsx | 32 ++++++++----------- .../components/input/__tests__/input.test.tsx | 3 +- .../components/select-input/useMultiple.tsx | 22 +++---------- .../components/select-input/useSingle.tsx | 22 +++---------- 4 files changed, 22 insertions(+), 57 deletions(-) diff --git a/packages/components/input/Input.tsx b/packages/components/input/Input.tsx index 2b01683aeb..47b2ee64ef 100644 --- a/packages/components/input/Input.tsx +++ b/packages/components/input/Input.tsx @@ -122,7 +122,6 @@ const Input = forwardRefWithStatics( // inputPreRef 用于预存输入框宽度,应用在 auto width 模式中 const inputPreRef: React.RefObject = useRef(null); const wrapperRef: React.RefObject = useRef(null); - const isClickingIconRef = useRef(false); const [isHover, toggleIsHover] = useState(false); const [isFocused, toggleIsFocused] = useState(false); @@ -139,14 +138,15 @@ const Input = forwardRefWithStatics( const prefixIconContent = renderIcon(classPrefix, 'prefix', parseTNode(prefixIcon)); let suffixIconNew = suffixIcon; - if (isShowClearIcon) + if (isShowClearIcon) { suffixIconNew = ( ); + } if (type === 'password' && typeof suffixIcon === 'undefined') { const PASSWORD_ICON_MAP = { password: BrowseOffIcon, @@ -155,11 +155,7 @@ const Input = forwardRefWithStatics( const PasswordIcon = PASSWORD_ICON_MAP[renderType]; if (PasswordIcon) { suffixIconNew = ( - + ); } } @@ -273,6 +269,7 @@ const Input = forwardRefWithStatics( })} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} + onMouseDown={handleMouseDown} onWheel={(e) => onWheel?.({ e })} onClick={(e) => { inputRef.current?.focus(); @@ -308,7 +305,6 @@ const Input = forwardRefWithStatics( requestAnimationFrame(() => { inputEl?.setSelectionRange(cursorPosition, cursorPosition); - isClickingIconRef.current = false; }); } @@ -328,20 +324,20 @@ const Input = forwardRefWithStatics( onChange(newStr, { e, trigger }); } } - // 添加MouseDown阻止冒泡,防止點擊Clear value會導致彈窗閃爍一下 - // https://github.com/Tencent/tdesign-react/issues/2320 - function handleMouseDown(e: React.MouseEvent) { + function handleIconMouseDown(e: React.MouseEvent) { + e.preventDefault(); + // 阻止冒泡,防止点击 icon 会导致弹窗闪烁一下 + // https://github.com/Tencent/tdesign-react/issues/2320 e.stopPropagation(); - // 兼容React16 + // 兼容 React 16 e.nativeEvent.stopImmediatePropagation(); - isClickingIconRef.current = true; + } + function handleMouseDown(e: React.MouseEvent) { + e.preventDefault(); // 防止焦点转移 } function handleClear(e: React.MouseEvent) { onChange?.('', { e, trigger: 'clear' }); onClear?.({ e }); - requestAnimationFrame(() => { - isClickingIconRef.current = false; - }); } function handleKeyDown(e: React.KeyboardEvent) { const { @@ -382,7 +378,6 @@ const Input = forwardRefWithStatics( } function handleFocus(e: React.FocusEvent) { - if (isClickingIconRef.current) return; const { currentTarget: { value }, } = e; @@ -392,7 +387,6 @@ const Input = forwardRefWithStatics( } function handleBlur(e: React.FocusEvent) { - if (isClickingIconRef.current) return; const { currentTarget: { value }, } = e; diff --git a/packages/components/input/__tests__/input.test.tsx b/packages/components/input/__tests__/input.test.tsx index 572142b38b..677f9c1f01 100644 --- a/packages/components/input/__tests__/input.test.tsx +++ b/packages/components/input/__tests__/input.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { render, fireEvent, vi, act, mockTimeout } from '@test/utils'; +import { render, fireEvent, vi, act } from '@test/utils'; import userEvent from '@testing-library/user-event'; import Input from '../Input'; @@ -47,7 +47,6 @@ describe('Input 组件测试', () => { fireEvent.mouseUp(clearIcon); fireEvent.click(clearIcon); expect(blurFn).toBeCalledTimes(0); - await mockTimeout(() => true); fireEvent.blur(InputDom); expect(blurFn).toBeCalledTimes(1); }); diff --git a/packages/components/select-input/useMultiple.tsx b/packages/components/select-input/useMultiple.tsx index 01bbeae28a..ef4b07d7aa 100644 --- a/packages/components/select-input/useMultiple.tsx +++ b/packages/components/select-input/useMultiple.tsx @@ -37,7 +37,6 @@ export default function useMultiple(props: SelectInputProps) { const [tInputValue, setTInputValue] = useControlled(props, 'inputValue', props.onInputChange); const tagInputRef = useRef(null); - const blurTimeoutRef = useRef(null); const iKeys: SelectInputKeys = { ...DEFAULT_KEYS, ...props.keys }; @@ -61,30 +60,17 @@ export default function useMultiple(props: SelectInputProps) { const renderSelectMultiple = (p: RenderSelectMultipleParams) => { const handleBlur = (value: SelectInputValue, context: { e: React.FocusEvent }) => { - if (blurTimeoutRef.current) { - clearTimeout(blurTimeoutRef.current); + if (!p.popupVisible) { + p.onInnerBlur(context); + } else if (!props.panel) { + props.onBlur?.(value, { e: context.e, inputValue: tInputValue, tagInputValue: tags }); } - // 强制把 popupVisible 设置为 false 时,点击 input,会出现 blur -> focus 的情况,因此忽略前面短暂的 blur 事件 - blurTimeoutRef.current = setTimeout(() => { - if (blurTimeoutRef.current) { - if (!p.popupVisible) { - p.onInnerBlur(context); - } else if (!props.panel) { - props.onBlur?.(value, { e: context.e, inputValue: tInputValue, tagInputValue: tags }); - } - } - blurTimeoutRef.current = null; - }, 150); }; const handleFocus = ( val: TagInputValue, context: { e: React.FocusEvent; inputValue: string }, ) => { - if (blurTimeoutRef.current) { - clearTimeout(blurTimeoutRef.current); - blurTimeoutRef.current = null; - } props.onFocus?.(props.value, { ...context, tagInputValue: val }); }; diff --git a/packages/components/select-input/useSingle.tsx b/packages/components/select-input/useSingle.tsx index 9e92324767..c14cf99fd0 100644 --- a/packages/components/select-input/useSingle.tsx +++ b/packages/components/select-input/useSingle.tsx @@ -48,7 +48,6 @@ export default function useSingle(props: TdSelectInputProps) { const { classPrefix } = useConfig(); const inputRef = useRef(null); - const blurTimeoutRef = useRef(null); const [inputValue, setInputValue] = useControlled(props, 'inputValue', props.onInputChange); @@ -78,27 +77,14 @@ export default function useSingle(props: TdSelectInputProps) { const displayedValue = popupVisible && props.allowInput ? inputValue : getInputValue(value, keys); const handleBlur = (value, ctx) => { - if (blurTimeoutRef.current) { - clearTimeout(blurTimeoutRef.current); + if (!popupVisible) { + onInnerBlur(ctx); + } else if (!props.panel) { + props.onBlur?.(value, { e: ctx.e, inputValue: value }); } - // 强制把 popupVisible 设置为 false 时,点击 input,会出现 blur -> focus 的情况,因此忽略前面短暂的 blur 事件 - blurTimeoutRef.current = setTimeout(() => { - if (blurTimeoutRef.current) { - if (!popupVisible) { - onInnerBlur(ctx); - } else if (!props.panel) { - props.onBlur?.(value, { e: ctx.e, inputValue: value }); - } - } - blurTimeoutRef.current = null; - }, 150); }; const handleFocus = (val, context) => { - if (blurTimeoutRef.current) { - clearTimeout(blurTimeoutRef.current); - blurTimeoutRef.current = null; - } props.onFocus?.(value, { ...context, inputValue: val }); // focus might not need to change input value. it will caught some curious errors in tree-select // !popupVisible && setInputValue(getInputValue(value, keys), { ...context, trigger: 'input' }); From be293575c9e73764052f6976b9aee56df94afba5 Mon Sep 17 00:00:00 2001 From: Rylan Date: Sat, 13 Dec 2025 01:13:49 +0800 Subject: [PATCH 5/5] fix(Input): prevent focus loss only on non-input elements --- packages/components/input/Input.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/components/input/Input.tsx b/packages/components/input/Input.tsx index 47b2ee64ef..8c9d736601 100644 --- a/packages/components/input/Input.tsx +++ b/packages/components/input/Input.tsx @@ -333,7 +333,9 @@ const Input = forwardRefWithStatics( e.nativeEvent.stopImmediatePropagation(); } function handleMouseDown(e: React.MouseEvent) { - e.preventDefault(); // 防止焦点转移 + if (e.target !== inputRef.current) { + e.preventDefault(); // 避免焦点转移 + } } function handleClear(e: React.MouseEvent) { onChange?.('', { e, trigger: 'clear' });