diff --git a/packages/components/_util/composeRefs.ts b/packages/components/_util/composeRefs.ts deleted file mode 100644 index 2e579a3427..0000000000 --- a/packages/components/_util/composeRefs.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Ref } from 'react'; - -// 同时处理多个 ref -export default function composeRefs(...refs: Ref[]) { - return (instance: T) => { - // eslint-disable-next-line no-restricted-syntax - for (const ref of refs) { - if (typeof ref === 'function') { - ref(instance); - } else if (ref) { - (ref as any).current = instance; - } - } - }; -} diff --git a/packages/components/_util/listener.ts b/packages/components/_util/listener.ts index a31b91e9ed..9b8caaa6da 100644 --- a/packages/components/_util/listener.ts +++ b/packages/components/_util/listener.ts @@ -2,9 +2,14 @@ import { canUseDocument } from './dom'; export const on = ((): any => { if (canUseDocument && document.addEventListener) { - return (element: Node, event: string, handler: EventListenerOrEventListenerObject): any => { + return ( + element: Node, + event: string, + handler: EventListenerOrEventListenerObject, + options?: AddEventListenerOptions, + ): any => { if (element && event && handler) { - element.addEventListener(event, handler, false); + element.addEventListener(event, handler, options ?? false); } }; } @@ -17,9 +22,14 @@ export const on = ((): any => { export const off = ((): any => { if (canUseDocument && document.removeEventListener) { - return (element: Node, event: string, handler: EventListenerOrEventListenerObject): any => { + return ( + element: Node, + event: string, + handler: EventListenerOrEventListenerObject, + options?: EventListenerOptions, + ): any => { if (element && event) { - element.removeEventListener(event, handler, false); + element.removeEventListener(event, handler, options ?? false); } }; } diff --git a/packages/components/_util/ref.ts b/packages/components/_util/ref.ts index 4038b814e9..189ee91d06 100644 --- a/packages/components/_util/ref.ts +++ b/packages/components/_util/ref.ts @@ -70,3 +70,17 @@ export const getNodeRef: (node: React.ReactNode) => React.Ref | null } return null; }; + +export function composeRefs(...refs: React.Ref[]) { + return (instance: T) => { + // eslint-disable-next-line no-restricted-syntax + for (const ref of refs) { + if (typeof ref === 'function') { + ref(instance); + } else if (ref) { + (ref as any).current = instance; + } + } + }; +} + diff --git a/packages/components/alert/Alert.tsx b/packages/components/alert/Alert.tsx index 13aae20c41..a938bdf939 100644 --- a/packages/components/alert/Alert.tsx +++ b/packages/components/alert/Alert.tsx @@ -16,7 +16,7 @@ import { useLocaleReceiver } from '../locale/LocalReceiver'; import { TdAlertProps } from './type'; import { StyledProps } from '../common'; import { alertDefaultProps } from './defaultProps'; -import composeRefs from '../_util/composeRefs'; +import { composeRefs } from '../_util/ref'; import useDefaultProps from '../hooks/useDefaultProps'; const transitionTime = 200; diff --git a/packages/components/avatar/Avatar.tsx b/packages/components/avatar/Avatar.tsx index c97a955fca..0460e46386 100644 --- a/packages/components/avatar/Avatar.tsx +++ b/packages/components/avatar/Avatar.tsx @@ -3,7 +3,7 @@ import classNames from 'classnames'; import useConfig from '../hooks/useConfig'; import forwardRefWithStatics from '../_util/forwardRefWithStatics'; import useCommonClassName from '../hooks/useCommonClassName'; -import composeRefs from '../_util/composeRefs'; +import { composeRefs } from '../_util/ref'; import { TdAvatarProps } from './type'; import { StyledProps } from '../common'; import AvatarContext from './AvatarContext'; diff --git a/packages/components/cascader/hooks.tsx b/packages/components/cascader/hooks.tsx index 2572eb92fd..6e5b04a25d 100644 --- a/packages/components/cascader/hooks.tsx +++ b/packages/components/cascader/hooks.tsx @@ -1,25 +1,25 @@ -import { useState, useEffect, useMemo, useRef } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { isArray, isEqual, isFunction } from 'lodash-es'; import TreeStore from '@tdesign/common-js/tree-v1/tree-store'; import type { TypeTreeNodeData } from '@tdesign/common-js/tree-v1/types'; -import { getTreeValue, getCascaderValue, isEmptyValues, isValueInvalid } from './core/helper'; -import { treeNodesEffect, treeStoreExpendEffect } from './core/effect'; import useControlled from '../hooks/useControlled'; +import useInnerPopupVisible from '../hooks/useInnerPopupVisible'; +import { treeNodesEffect, treeStoreExpendEffect } from './core/effect'; +import { getCascaderValue, getTreeValue, isEmptyValues, isValueInvalid } from './core/helper'; +import type { TreeOptionData } from '../common'; import type { - TreeNode, - TreeNodeValue, - TdCascaderProps, - TreeNodeModel, CascaderChangeSource, CascaderValue, + TdCascaderProps, + TreeNode, + TreeNodeModel, + TreeNodeValue, } from './interface'; -import { TreeOptionData } from '../common'; - export const useCascaderContext = (props: TdCascaderProps) => { const [innerValue, setInnerValue] = useControlled(props, 'value', props.onChange); const [innerPopupVisible, setPopupVisible] = useControlled(props, 'popupVisible', props.onPopupVisibleChange); @@ -30,6 +30,10 @@ export const useCascaderContext = (props: TdCascaderProps) => { const [expend, setExpend] = useState([]); const [scopeVal, setScopeVal] = useState(undefined); + const handlePopupVisibleChange = useInnerPopupVisible((v, ctx) => { + setPopupVisible(v, ctx); + }); + const cascaderContext = useMemo(() => { const { size, @@ -65,14 +69,14 @@ export const useCascaderContext = (props: TdCascaderProps) => { setInnerValue(val, { source, node }); }, visible: innerPopupVisible, - setVisible: setPopupVisible, + setVisible: handlePopupVisibleChange, treeNodes, setTreeNodes, inputVal, setInputVal, setExpend, }; - }, [props, scopeVal, innerPopupVisible, treeStore, treeNodes, inputVal, setInnerValue, setPopupVisible]); + }, [props, scopeVal, innerPopupVisible, treeStore, treeNodes, inputVal, setInnerValue, handlePopupVisibleChange]); const isFilterable = useMemo( () => Boolean(props.filterable || isFunction(props.filter)), diff --git a/packages/components/date-picker/hooks/useRange.tsx b/packages/components/date-picker/hooks/useRange.tsx index a6a7e7d6f7..d09ac9ba9b 100644 --- a/packages/components/date-picker/hooks/useRange.tsx +++ b/packages/components/date-picker/hooks/useRange.tsx @@ -1,13 +1,16 @@ -import React, { useState, useEffect, useRef } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { CalendarIcon as TdCalendarIcon } from 'tdesign-icons-react'; import classNames from 'classnames'; -import { isValidDate, formatDate, getDefaultFormat, parseToDayjs } from '@tdesign/common-js/date-picker/format'; + +import { formatDate, getDefaultFormat, isValidDate, parseToDayjs } from '@tdesign/common-js/date-picker/format'; import useConfig from '../../hooks/useConfig'; import useGlobalIcon from '../../hooks/useGlobalIcon'; -import { RangeInputRefInterface } from '../../range-input'; -import { TdDateRangePickerProps, DateValue } from '../type'; +import useInnerPopupVisible from '../../hooks/useInnerPopupVisible'; import useRangeValue from './useRangeValue'; + import type { TdPopupProps } from '../../popup/type'; +import type { RangeInputRefInterface } from '../../range-input'; +import type { DateValue, TdDateRangePickerProps } from '../type'; export const PARTIAL_MAP = { first: 'start', second: 'end' }; @@ -123,6 +126,19 @@ export default function useRange(props: TdDateRangePickerProps) { }, }; + const handleInnerVisibleChange = useInnerPopupVisible((visible, context) => { + if (props.disabled) return; + // 这里劫持了进一步向 popup 传递的 onVisibleChange 事件,为了保证可以在 Datepicker 中使用 popupProps.onVisibleChange,故此处理 + props.popupProps?.onVisibleChange?.(visible, context); + // 输入框点击不关闭面板 + if (context.trigger === 'trigger-element-mousedown') { + const indexMap = { 0: 'first', 1: 'second' }; + inputRef.current.focus({ position: indexMap[activeIndex] }); + return setPopupVisible(true); + } + setPopupVisible(visible); + }); + // popup 设置 const popupProps = { expandAnimation: true, @@ -130,19 +146,7 @@ export default function useRange(props: TdDateRangePickerProps) { trigger: 'mousedown' as TdPopupProps['trigger'], overlayInnerStyle: props.popupProps?.overlayInnerStyle ?? { width: 'auto' }, overlayClassName: classNames(props.popupProps?.overlayClassName, `${name}__panel-container`), - onVisibleChange: (visible: boolean, context) => { - if (props.disabled) return; - // 这里劫持了进一步向 popup 传递的 onVisibleChange 事件,为了保证可以在 Datepicker 中使用 popupProps.onVisibleChange,故此处理 - props.popupProps?.onVisibleChange?.(visible, context); - // 输入框点击不关闭面板 - if (context.trigger === 'trigger-element-mousedown') { - const indexMap = { 0: 'first', 1: 'second' }; - inputRef.current.focus({ position: indexMap[activeIndex] }); - return setPopupVisible(true); - } - - setPopupVisible(visible); - }, + onVisibleChange: handleInnerVisibleChange, }; // 输入框响应 value 变化 diff --git a/packages/components/date-picker/hooks/useSingle.tsx b/packages/components/date-picker/hooks/useSingle.tsx index 87794757b2..4dc170867a 100644 --- a/packages/components/date-picker/hooks/useSingle.tsx +++ b/packages/components/date-picker/hooks/useSingle.tsx @@ -1,8 +1,9 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { CalendarIcon as TdCalendarIcon } from 'tdesign-icons-react'; import classNames from 'classnames'; import dayjs from 'dayjs'; import { omit } from 'lodash-es'; -import React, { useEffect, useRef, useState } from 'react'; -import { CalendarIcon as TdCalendarIcon } from 'tdesign-icons-react'; + import { formatDate, formatTime, @@ -12,9 +13,11 @@ import { } from '@tdesign/common-js/date-picker/format'; import useConfig from '../../hooks/useConfig'; import useGlobalIcon from '../../hooks/useGlobalIcon'; +import useInnerPopupVisible from '../../hooks/useInnerPopupVisible'; +import useSingleValue from './useSingleValue'; + import type { TdPopupProps } from '../../popup/type'; import type { TdDatePickerProps } from '../type'; -import useSingleValue from './useSingleValue'; export default function useSingleInput(props: TdDatePickerProps) { const { classPrefix, datePicker: globalDatePickerConfig } = useConfig(); @@ -105,6 +108,16 @@ export default function useSingleInput(props: TdDatePickerProps) { }, }; + const handleInnerVisibleChange = useInnerPopupVisible((visible, context) => { + if (props.disabled) return; + // 这里劫持了进一步向 popup 传递的 onVisibleChange 事件,为了保证可以在 Datepicker 中使用 popupProps.onVisibleChange,故此处理 + props.popupProps?.onVisibleChange?.(visible, context); + if (context.trigger === 'trigger-element-mousedown') { + return setPopupVisible(true); + } + setPopupVisible(visible); + }); + // popup 设置 let popupProps = { expandAnimation: true, @@ -112,15 +125,7 @@ export default function useSingleInput(props: TdDatePickerProps) { trigger: 'mousedown' as TdPopupProps['trigger'], overlayInnerStyle: props.popupProps?.overlayInnerStyle ?? { width: 'auto' }, overlayClassName: classNames(props.popupProps?.overlayClassName, `${name}__panel-container`), - onVisibleChange: (visible: boolean, context: any) => { - if (props.disabled) return; - // 这里劫持了进一步向 popup 传递的 onVisibleChange 事件,为了保证可以在 Datepicker 中使用 popupProps.onVisibleChange,故此处理 - props.popupProps?.onVisibleChange?.(visible, context); - if (context.trigger === 'trigger-element-mousedown') { - return setPopupVisible(true); - } - setPopupVisible(visible); - }, + onVisibleChange: handleInnerVisibleChange, }; // tag-input 设置 diff --git a/packages/components/hooks/useInnerPopupVisible.tsx b/packages/components/hooks/useInnerPopupVisible.tsx new file mode 100644 index 0000000000..a2b34d12b1 --- /dev/null +++ b/packages/components/hooks/useInnerPopupVisible.tsx @@ -0,0 +1,25 @@ +import useConfig from './useConfig'; +import type { PopupVisibleChangeContext } from '../popup'; + +function useInnerPopupVisible(handler: (visible: boolean, ctx: PopupVisibleChangeContext) => void) { + const { classPrefix } = useConfig(); + + const isClearIconClick = (target: EventTarget | null): boolean => { + if (!target || !(target instanceof Element)) return false; + return !!( + target?.closest?.(`.${classPrefix}-input__suffix-clear`) || + target?.closest?.(`.${classPrefix}-tag-input__suffix-clear`) || + target?.closest?.(`.${classPrefix}-range-input__suffix-clear`) + ); + }; + + return (visible: boolean, ctx: PopupVisibleChangeContext) => { + // Fix: https://github.com/Tencent/tdesign-react/issues/2320 + // 点击 clear icon 是否触发 Popup 显隐切换的逻辑交给具体逻辑自己处理,避免重复触发 + if (isClearIconClick(ctx?.e?.target) && visible) return; + // 执行原函数 + handler(visible, ctx); + }; +} + +export default useInnerPopupVisible; diff --git a/packages/components/input/Input.tsx b/packages/components/input/Input.tsx index 40d5c99d91..7f91ebd939 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需要撑开宽度 @@ -136,13 +138,7 @@ const Input = forwardRefWithStatics( let suffixIconNew = suffixIcon; if (isShowClearIcon) - suffixIconNew = ( - - ); + suffixIconNew = ; if (type === 'password' && typeof suffixIcon === 'undefined') { if (renderType === 'password') { suffixIconNew = ( @@ -316,13 +312,6 @@ const Input = forwardRefWithStatics( onChange(newStr, { e, trigger }); } } - // 添加MouseDown阻止冒泡,防止點擊Clear value會導致彈窗閃爍一下 - // https://github.com/Tencent/tdesign-react/issues/2320 - function handleMouseDown(e: React.MouseEvent) { - e.stopPropagation(); - // 兼容React16 - e.nativeEvent.stopImmediatePropagation(); - } function handleClear(e: React.MouseEvent) { onChange?.('', { e, trigger: 'clear' }); onClear?.({ e }); diff --git a/packages/components/popup/Popup.tsx b/packages/components/popup/Popup.tsx index 52f5ffbdd8..da2c8f98dc 100644 --- a/packages/components/popup/Popup.tsx +++ b/packages/components/popup/Popup.tsx @@ -3,7 +3,7 @@ import classNames from 'classnames'; import { debounce, isFunction } from 'lodash-es'; import React, { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; import { CSSTransition } from 'react-transition-group'; -import { getRefDom } from '../_util/ref'; + import { getCssVarsValue } from '../_util/style'; import Portal from '../common/Portal'; import useAnimation from '../hooks/useAnimation'; @@ -16,9 +16,10 @@ import usePopper from '../hooks/usePopper'; import useWindowSize from '../hooks/useWindowSize'; import { popupDefaultProps } from './defaultProps'; import useTrigger from './hooks/useTrigger'; -import type { TdPopupProps } from './type'; import { getTransitionParams } from './utils/transition'; +import type { TdPopupProps } from './type'; + export interface PopupProps extends TdPopupProps { // 是否触发展开收起动画,内部下拉式组件使用 expandAnimation?: boolean; @@ -39,6 +40,9 @@ export interface PopupRef { setVisible: (visible: boolean) => void; } +// 默认动画时长 +const DEFAULT_TRANSITION_TIMEOUT = 180; + const Popup = forwardRef((originalProps, ref) => { const props = useDefaultProps(originalProps, popupDefaultProps); const { @@ -72,15 +76,11 @@ const Popup = forwardRef((originalProps, ref) => { const [visible, onVisibleChange] = useControlled(props, 'visible', props.onVisibleChange); const [popupElement, setPopupElement] = useState(null); - const triggerRef = useRef(null); // 记录 trigger 元素 const popupRef = useRef(null); // popup dom 元素,css transition 需要用 const portalRef = useRef(null); // portal dom 元素 const contentRef = useRef(null); // 内容部分 const popperRef = useRef(null); // 保存 popper 实例 - // 默认动画时长 - const DEFAULT_TRANSITION_TIMEOUT = 180; - // 处理切换 panel 为 null 和正常内容动态切换的情况 useEffect(() => { if (!content && hideEmptyPopup) { @@ -100,8 +100,8 @@ const Popup = forwardRef((originalProps, ref) => { [placement], ); - const { getTriggerNode, getPopupProps, getTriggerDom } = useTrigger({ - triggerRef, + const { triggerElementIsString, getTriggerElement, getTriggerNode } = useTrigger({ + triggerElement, content, disabled, trigger, @@ -109,9 +109,10 @@ const Popup = forwardRef((originalProps, ref) => { delay, onVisibleChange, }); + const triggerEl = getTriggerElement(); const popperOptions = props.popperOptions as Options; - popperRef.current = usePopper(getRefDom(triggerRef), popupElement, { + popperRef.current = usePopper(triggerEl, popupElement, { placement: popperPlacement, ...popperOptions, }); @@ -128,8 +129,8 @@ const Popup = forwardRef((originalProps, ref) => { const updateTimeRef = useRef(null); // 监听 trigger 节点或内容变化动态更新 popup 定位 - useMutationObserver(getRefDom(triggerRef), () => { - const isDisplayNone = getCssVarsValue('display', getRefDom(triggerRef)) === 'none'; + useMutationObserver(triggerEl, () => { + const isDisplayNone = getCssVarsValue('display', triggerEl) === 'none'; if (visible && !isDisplayNone) { clearTimeout(updateTimeRef.current); updateTimeRef.current = setTimeout(() => popperRef.current?.update?.(), 0); @@ -146,11 +147,10 @@ const Popup = forwardRef((originalProps, ref) => { // 下拉展开时更新内部滚动条 useEffect(() => { - if (!triggerRef.current) triggerRef.current = getTriggerDom(); if (visible) { updateScrollTop?.(contentRef.current); } - }, [visible, updateScrollTop, getTriggerDom]); + }, [visible, updateScrollTop]); function handleExited() { !destroyOnClose && popupElement && (popupElement.style.display = 'none'); @@ -175,8 +175,8 @@ const Popup = forwardRef((originalProps, ref) => { // 整理浮层样式 function getOverlayStyle(overlayStyle: TdPopupProps['overlayStyle']) { - if (getRefDom(triggerRef) && popupRef.current && typeof overlayStyle === 'function') { - return { ...overlayStyle(getRefDom(triggerRef), popupRef.current) }; + if (triggerEl && popupRef.current && typeof overlayStyle === 'function') { + return { ...overlayStyle(triggerEl, popupRef.current) }; } return { ...overlayStyle }; } @@ -191,7 +191,7 @@ const Popup = forwardRef((originalProps, ref) => { onEnter={handleEnter} onExited={handleExited} > - + ((originalProps, ref) => { style={{ ...styles.popper, zIndex, ...getOverlayStyle(overlayStyle) }} className={classNames(`${classPrefix}-popup`, overlayClassName)} {...attributes.popper} - {...getPopupProps()} onClick={(e) => props.onOverlayClick?.({ e })} >
((originalProps, ref) => { return ( - {triggerNode} + {triggerElementIsString ? null : triggerNode} {overlay} ); diff --git a/packages/components/popup/PopupPlugin.tsx b/packages/components/popup/PopupPlugin.tsx index 3fc310475b..442595795c 100644 --- a/packages/components/popup/PopupPlugin.tsx +++ b/packages/components/popup/PopupPlugin.tsx @@ -1,16 +1,19 @@ -import { createPopper, Instance, Placement, type Options } from '@popperjs/core'; -import classNames from 'classnames'; -import { isString } from 'lodash-es'; import React, { useLayoutEffect, useMemo, useRef, useState } from 'react'; import { CSSTransition } from 'react-transition-group'; +import { createPopper, type Instance, type Placement, type Options } from '@popperjs/core'; +import classNames from 'classnames'; +import { isString } from 'lodash-es'; + import { getAttach } from '../_util/dom'; import { off, on } from '../_util/listener'; import { render, unmount } from '../_util/react-render'; -import type { TNode } from '../common'; import PluginContainer from '../common/PluginContainer'; import ConfigProvider from '../config-provider'; +import useConfig from '../hooks/useConfig'; import useDefaultProps from '../hooks/useDefaultProps'; import { popupDefaultProps } from './defaultProps'; + +import type { TNode } from '../common'; import type { TdPopupProps } from './type'; export interface PopupPluginApi { @@ -31,8 +34,6 @@ let overlayInstance: HTMLElement; let timeout: NodeJS.Timeout; let triggerEl: HTMLElement; -const componentName = 't-popup'; - const triggerType = (triggerProps: string) => triggers.reduce( (map, trigger) => ({ @@ -62,6 +63,9 @@ const Overlay: React.FC = (originalProps) => { renderCallback, } = props; + const { classPrefix } = useConfig(); + const componentName = `${classPrefix}-popup`; + const [visibleState, setVisibleState] = useState(false); const popperRef = useRef(null); const overlayRef = useRef(null); @@ -80,7 +84,6 @@ const Overlay: React.FC = (originalProps) => { }; }; - // useMemo const hasTrigger = useMemo(() => triggerType(trigger), [trigger]); const overlayClasses = useMemo( () => [ @@ -92,7 +95,7 @@ const Overlay: React.FC = (originalProps) => { }, overlayInnerClassName, ], - [content, overlayInnerClassName, showArrow, disabled], + [componentName, content, showArrow, disabled, overlayInnerClassName], ); // method diff --git a/packages/components/popup/hooks/useTrigger.tsx b/packages/components/popup/hooks/useTrigger.tsx index fe13b2c8d9..0b02b971f9 100644 --- a/packages/components/popup/hooks/useTrigger.tsx +++ b/packages/components/popup/hooks/useTrigger.tsx @@ -1,23 +1,23 @@ -import React, { useRef, useEffect, isValidElement, useCallback, useMemo } from 'react'; -import { isFragment } from 'react-is'; -import classNames from 'classnames'; -import { supportRef, getRefDom, getNodeRef } from '../../_util/ref'; -import composeRefs from '../../_util/composeRefs'; +import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import { off, on } from '../../_util/listener'; +import { composeRefs, getNodeRef, getRefDom, supportNodeRef } from '../../_util/ref'; +import useConfig from '../../hooks/useConfig'; const ESC_KEY = 'Escape'; -export default function useTrigger({ content, disabled, trigger, visible, onVisibleChange, triggerRef, delay }) { - const hasPopupMouseDown = useRef(false); - const mouseDownTimer = useRef(0); +export default function useTrigger({ triggerElement, content, disabled, trigger, visible, onVisibleChange, delay }) { + const { classPrefix } = useConfig(); + + const triggerElementIsString = typeof triggerElement === 'string'; + + const triggerRef = useRef(null); const visibleTimer = useRef(null); - const triggerDataKey = useRef(`t-popup--${Math.random().toFixed(10)}`); const leaveFlag = useRef(false); // 防止多次触发显隐 // 禁用和无内容时不展示 const shouldToggle = useMemo(() => { - if (disabled) return false; // 禁用 - return !disabled && content === 0 ? true : content; // 无内容时 + if (disabled) return false; + return !!content; }, [disabled, content]); // 解析 delay 数据类型 @@ -35,179 +35,180 @@ export default function useTrigger({ content, disabled, trigger, visible, onVisi } } - // 点击 trigger overlay 以外的元素关闭 + const getTriggerElement = useCallback(() => { + let element: HTMLElement | null = null; + if (triggerElementIsString) { + element = document.querySelector(triggerElement); + } else { + element = getRefDom(triggerRef); + } + return element; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => clearTimeout(visibleTimer.current), []); + useEffect(() => { if (!shouldToggle) return; - const handleDocumentClick = (e: any) => { - if (getRefDom(triggerRef)?.contains?.(e.target) || hasPopupMouseDown.current) { - return; + const element = getTriggerElement(); + if (!element) return; + + const handleClick = (e: MouseEvent) => { + if (trigger === 'click') { + callFuncWithDelay({ + delay: visible ? appearDelay : exitDelay, + callback: () => onVisibleChange(!visible, { e, trigger: 'trigger-element-click' }), + }); } - visible && onVisibleChange(false, { e, trigger: 'document' }); }; - on(document, 'mousedown', handleDocumentClick); - on(document, 'touchend', handleDocumentClick); - return () => { - off(document, 'mousedown', handleDocumentClick); - off(document, 'touchend', handleDocumentClick); + + const handleMouseDown = (e: MouseEvent) => { + if (trigger === 'mousedown') { + callFuncWithDelay({ + delay: visible ? appearDelay : exitDelay, + callback: () => onVisibleChange(!visible, { e, trigger: 'trigger-element-mousedown' }), + }); + } }; - }, [shouldToggle, visible, onVisibleChange, triggerRef]); - - // 弹出内容交互处理 - function getPopupProps(): any { - if (!shouldToggle) return {}; - - return { - onMouseEnter: (e: MouseEvent) => { - if (trigger === 'hover' && !leaveFlag.current) { - clearTimeout(visibleTimer.current); - onVisibleChange(true, { e, trigger: 'trigger-element-hover' }); - } - }, - onMouseLeave: (e: MouseEvent) => { - if (trigger === 'hover') { - leaveFlag.current = true; - clearTimeout(visibleTimer.current); - onVisibleChange(false, { e, trigger: 'trigger-element-hover' }); - } - }, - onMouseDown: () => { - clearTimeout(mouseDownTimer.current); - hasPopupMouseDown.current = true; - mouseDownTimer.current = window.setTimeout(() => { - hasPopupMouseDown.current = false; + + const handleMouseEnter = (e: MouseEvent) => { + if (trigger === 'hover') { + leaveFlag.current = false; + callFuncWithDelay({ + delay: appearDelay, + callback: () => onVisibleChange(true, { e, trigger: 'trigger-element-hover' }), }); - }, - onTouchEnd: () => { - clearTimeout(mouseDownTimer.current); - hasPopupMouseDown.current = true; - mouseDownTimer.current = window.setTimeout(() => { - hasPopupMouseDown.current = false; + } + }; + + const handleMouseLeave = (e: MouseEvent) => { + if (trigger === 'hover') { + leaveFlag.current = false; + callFuncWithDelay({ + delay: exitDelay, + callback: () => onVisibleChange(false, { e, trigger: 'trigger-element-hover' }), }); - }, + } + }; + + const handleFocus = (e: FocusEvent) => { + if (trigger === 'focus') { + callFuncWithDelay({ + delay: appearDelay, + callback: () => onVisibleChange(true, { e, trigger: 'trigger-element-focus' }), + }); + } + }; + + const handleBlur = (e: FocusEvent) => { + if (trigger === 'focus') { + callFuncWithDelay({ + delay: exitDelay, + callback: () => onVisibleChange(false, { e, trigger: 'trigger-element-blur' }), + }); + } }; - } - // 整理 trigger props - function getTriggerProps(triggerNode: React.ReactElement) { - if (!shouldToggle) return {}; - - const triggerProps: any = { - className: visible ? classNames(triggerNode.props.className, `t-popup-open`) : triggerNode.props.className, - onMouseDown: (e: MouseEvent) => { - if (trigger === 'mousedown') { - callFuncWithDelay({ - delay: visible ? appearDelay : exitDelay, - callback: () => onVisibleChange(!visible, { e, trigger: 'trigger-element-mousedown' }), - }); - } - triggerNode.props.onMouseDown?.(e); - }, - onClick: (e: MouseEvent) => { - if (trigger === 'click') { - callFuncWithDelay({ - delay: visible ? appearDelay : exitDelay, - callback: () => onVisibleChange(!visible, { e, trigger: 'trigger-element-click' }), - }); - } - triggerNode.props.onClick?.(e); - }, - onTouchStart: (e: TouchEvent) => { - if (trigger === 'hover' || trigger === 'mousedown') { - leaveFlag.current = false; - callFuncWithDelay({ - delay: appearDelay, - callback: () => onVisibleChange(true, { e, trigger: 'trigger-element-hover' }), - }); - } - triggerNode.props.onTouchStart?.(e); - }, - onMouseEnter: (e: MouseEvent) => { - if (trigger === 'hover') { - leaveFlag.current = false; - callFuncWithDelay({ - delay: appearDelay, - callback: () => onVisibleChange(true, { e, trigger: 'trigger-element-hover' }), - }); - } - triggerNode.props.onMouseEnter?.(e); - }, - onMouseLeave: (e: MouseEvent) => { - if (trigger === 'hover') { - leaveFlag.current = false; - callFuncWithDelay({ - delay: exitDelay, - callback: () => onVisibleChange(false, { e, trigger: 'trigger-element-hover' }), - }); - } - triggerNode.props.onMouseLeave?.(e); - }, - onFocus: (...args: any) => { - if (trigger === 'focus') { - callFuncWithDelay({ - delay: appearDelay, - callback: () => onVisibleChange(true, { trigger: 'trigger-element-focus' }), - }); - } - triggerNode.props.onFocus?.(...args); - }, - onBlur: (...args: any) => { - if (trigger === 'focus') { - callFuncWithDelay({ - delay: appearDelay, - callback: () => onVisibleChange(false, { trigger: 'trigger-element-blur' }), - }); - } - triggerNode.props.onBlur?.(...args); - }, - onContextMenu: (e: MouseEvent) => { - if (trigger === 'context-menu') { - e.preventDefault(); - callFuncWithDelay({ - delay: appearDelay, - callback: () => onVisibleChange(true, { e, trigger: 'context-menu' }), - }); - } - triggerNode.props.onContextMenu?.(e); - }, - onKeyDown: (e: KeyboardEvent) => { - if (e?.key === ESC_KEY) { - callFuncWithDelay({ - delay: exitDelay, - callback: () => onVisibleChange(false, { e, trigger: 'keydown-esc' }), - }); - } - triggerNode.props.onKeyDown?.(e); - }, + const handleContextMenu = (e: MouseEvent) => { + if (trigger === 'context-menu') { + e.preventDefault(); + callFuncWithDelay({ + delay: appearDelay, + callback: () => onVisibleChange(true, { e, trigger: 'context-menu' }), + }); + } }; - if (supportRef(triggerNode)) { - triggerProps.ref = composeRefs(triggerRef, getNodeRef(triggerNode as any)); + const handleTouchStart = (e: TouchEvent) => { + if (trigger === 'hover' || trigger === 'mousedown') { + leaveFlag.current = false; + callFuncWithDelay({ + delay: appearDelay, + callback: () => onVisibleChange(true, { e, trigger: 'trigger-element-hover' }), + }); + } + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e?.key === ESC_KEY) { + callFuncWithDelay({ + delay: exitDelay, + callback: () => onVisibleChange(false, { e, trigger: 'keydown-esc' }), + }); + } + }; + + on(element, 'click', handleClick); + on(element, 'mousedown', handleMouseDown); + on(element, 'mouseenter', handleMouseEnter); + on(element, 'mouseleave', handleMouseLeave); + on(element, 'focus', handleFocus); + on(element, 'blur', handleBlur); + on(element, 'contextmenu', handleContextMenu); + on(element, 'touchstart', handleTouchStart, { passive: true }); + on(element, 'keydown', handleKeyDown); + return () => { + off(element, 'click', handleClick); + off(element, 'mousedown', handleMouseDown); + off(element, 'mouseenter', handleMouseEnter); + off(element, 'mouseleave', handleMouseLeave); + off(element, 'focus', handleFocus); + off(element, 'blur', handleBlur); + off(element, 'contextmenu', handleContextMenu); + off(element, 'touchstart', handleTouchStart, { passive: true }); + off(element, 'keydown', handleKeyDown); + }; + }, [classPrefix, shouldToggle, appearDelay, exitDelay, trigger, visible, onVisibleChange, getTriggerElement]); + + useEffect(() => { + if (!shouldToggle) return; + + const handleDocumentClick = (e: any) => { + const element = getTriggerElement(); + if (element?.contains?.(e.target) || e.target?.closest?.(`.${classPrefix}-popup`)) return; + visible && onVisibleChange(false, { e, trigger: 'document' }); + }; + + on(document, 'mousedown', handleDocumentClick); + on(document, 'touchend', handleDocumentClick, { passive: true }); + return () => { + off(document, 'mousedown', handleDocumentClick); + off(document, 'touchend', handleDocumentClick, { passive: true }); + }; + }, [classPrefix, shouldToggle, visible, onVisibleChange, getTriggerElement]); + + useEffect(() => { + const element = getTriggerElement(); + if (visible) { + element?.classList.add(`${classPrefix}-popup-open`); } else { - // 标记 trigger 元素 - triggerProps['data-popup'] = triggerDataKey.current; + element?.classList.remove(`${classPrefix}-popup-open`); } + return () => { + element?.classList.remove(`${classPrefix}-popup-open`); + }; + }, [visible, classPrefix, getTriggerElement]); - return triggerProps; - } - - // 整理 trigger 元素 function getTriggerNode(children: React.ReactNode) { - const triggerNode = - isValidElement(children) && !isFragment(children) ? children : {children}; + if (triggerElementIsString) return; - return React.cloneElement(triggerNode, getTriggerProps(triggerNode)); - } + if (supportNodeRef(children)) { + const childRef = getNodeRef(children); + const mergedRef = childRef ? composeRefs(triggerRef, childRef) : triggerRef; + return React.cloneElement(children, { ref: mergedRef }); + } - // ref 透传失败时使用 dom 查找 - const getTriggerDom = useCallback(() => { - if (typeof document === 'undefined') return {}; - return document.querySelector(`[data-popup="${triggerDataKey.current}"]`); - }, []); + return ( + + {children} + + ); + } return { + triggerElementIsString, + getTriggerElement, getTriggerNode, - getPopupProps, - getTriggerDom, }; } diff --git a/packages/components/range-input/RangeInput.tsx b/packages/components/range-input/RangeInput.tsx index 6a21a8bb7c..75db527d10 100644 --- a/packages/components/range-input/RangeInput.tsx +++ b/packages/components/range-input/RangeInput.tsx @@ -92,7 +92,7 @@ const RangeInput = React.forwardRef + ); } @@ -101,14 +101,6 @@ const RangeInput = React.forwardRef) { - e.stopPropagation(); - // 兼容React16 - e.nativeEvent.stopImmediatePropagation(); - } - function handleClear(e: React.MouseEvent) { onClear?.({ e }); onChange?.(['', ''], { e, trigger: 'clear', position: 'all' }); diff --git a/packages/components/select-input/useOverlayInnerStyle.ts b/packages/components/select-input/useOverlayInnerStyle.ts index 75646cc637..53dc37716b 100644 --- a/packages/components/select-input/useOverlayInnerStyle.ts +++ b/packages/components/select-input/useOverlayInnerStyle.ts @@ -1,7 +1,8 @@ -import { isFunction, isObject } from 'lodash-es'; import React, { useMemo, useRef } from 'react'; +import { isFunction, isObject } from 'lodash-es'; import useControlled from '../hooks/useControlled'; +import useInnerPopupVisible from '../hooks/useInnerPopupVisible'; import type { PopupVisibleChangeContext, TdPopupProps } from '../popup'; import type { TdSelectInputProps } from './type'; @@ -70,7 +71,7 @@ export default function useOverlayInnerStyle( }; }; - const onInnerPopupVisibleChange = (visible: boolean, context: PopupVisibleChangeContext) => { + const onInnerPopupVisibleChange = useInnerPopupVisible((visible: boolean, context: PopupVisibleChangeContext) => { skipNextBlur.current = false; if (disabled || readonly) return; // 如果点击触发元素(输入框)且为可输入状态,则继续显示下拉框 @@ -82,7 +83,7 @@ export default function useOverlayInnerStyle( skipNextBlur.current = true; } } - }; + }); const tOverlayInnerStyle = useMemo(() => { let result: TdPopupProps['overlayInnerStyle'] = {}; diff --git a/packages/components/select/base/Select.tsx b/packages/components/select/base/Select.tsx index f031d9d3d4..61bf93d181 100644 --- a/packages/components/select/base/Select.tsx +++ b/packages/components/select/base/Select.tsx @@ -10,7 +10,7 @@ import React, { } from 'react'; import classNames from 'classnames'; import { debounce, get, isFunction } from 'lodash-es'; -import composeRefs from '../../_util/composeRefs'; +import { composeRefs } from '../../_util/ref'; import forwardRefWithStatics from '../../_util/forwardRefWithStatics'; import { getOffsetTopToContainer } from '../../_util/helper'; import noop from '../../_util/noop'; diff --git a/packages/components/tooltip/TooltipLite.tsx b/packages/components/tooltip/TooltipLite.tsx index 234c28f1be..1e8ec6dd8f 100644 --- a/packages/components/tooltip/TooltipLite.tsx +++ b/packages/components/tooltip/TooltipLite.tsx @@ -130,6 +130,6 @@ const TooltipLite: React.FC = (originalProps) => { ); }; -TooltipLite.displayName = 'Tooltiplite'; +TooltipLite.displayName = 'TooltipLite'; export default React.memo(TooltipLite); diff --git a/packages/components/tree-select/TreeSelect.tsx b/packages/components/tree-select/TreeSelect.tsx index 539764850a..a741993e46 100644 --- a/packages/components/tree-select/TreeSelect.tsx +++ b/packages/components/tree-select/TreeSelect.tsx @@ -1,25 +1,28 @@ -import React, { useCallback, useMemo, useRef, forwardRef, ElementRef, useImperativeHandle } from 'react'; -import { isFunction } from 'lodash-es'; +import React, { ElementRef, forwardRef, useCallback, useImperativeHandle, useMemo, useRef } from 'react'; import classNames from 'classnames'; -import type { TdTreeSelectProps, TreeSelectValue } from './type'; -import type { StyledProps, TreeOptionData } from '../common'; +import { isFunction } from 'lodash-es'; + +import noop from '../_util/noop'; +import parseTNode from '../_util/parseTNode'; import useConfig from '../hooks/useConfig'; import useControlled from '../hooks/useControlled'; -import Tree from '../tree'; -import type { TreeInstanceFunctions, TreeProps } from '../tree'; -import SelectInput, { SelectInputProps } from '../select-input/SelectInput'; +import useDefaultProps from '../hooks/useDefaultProps'; +import useInnerPopupVisible from '../hooks/useInnerPopupVisible'; import { usePersistFn } from '../hooks/usePersistFn'; import useSwitch from '../hooks/useSwitch'; -import noop from '../_util/noop'; +import SelectInput, { type SelectInputProps } from '../select-input/SelectInput'; +import Tree from '../tree'; +import { treeSelectDefaultProps } from './defaultProps'; +import { useTreeSelectLocale } from './hooks/useTreeSelectLocale'; +import { useTreeSelectPassThroughProps } from './hooks/useTreeSelectPassthroughProps'; import { useTreeSelectUtils } from './hooks/useTreeSelectUtils'; import { SelectArrow } from './SelectArrow'; -import { useTreeSelectPassThroughProps } from './hooks/useTreeSelectPassthroughProps'; -import { useTreeSelectLocale } from './hooks/useTreeSelectLocale'; -import { treeSelectDefaultProps } from './defaultProps'; -import parseTNode from '../_util/parseTNode'; -import useDefaultProps from '../hooks/useDefaultProps'; -import { PopupRef } from '../popup'; -import { InputRef } from '../input'; + +import type { StyledProps, TreeOptionData } from '../common'; +import type { InputRef } from '../input'; +import type { PopupRef } from '../popup'; +import type { TreeInstanceFunctions, TreeProps } from '../tree'; +import type { TdTreeSelectProps, TreeSelectValue } from './type'; export interface TreeSelectProps extends TdTreeSelectProps, @@ -221,9 +224,9 @@ const TreeSelect = forwardRef((originalProps } }); - const onInnerPopupVisibleChange: SelectInputProps['onPopupVisibleChange'] = (visible, ctx) => { + const handlePopupVisibleChange = useInnerPopupVisible((visible, ctx) => { setPopupVisible(visible, { e: ctx.e }); - }; + }); const handleClear = usePersistFn((ctx) => { ctx.e.stopPropagation(); @@ -337,7 +340,7 @@ const TreeSelect = forwardRef((originalProps placeholder={inputPlaceholder} popupVisible={popupVisible && !disabled} onInputChange={handleFilterChange} - onPopupVisibleChange={onInnerPopupVisibleChange} + onPopupVisibleChange={handlePopupVisibleChange} onFocus={useMergeFn(handleFocus)} onBlur={useMergeFn(handleBlur)} onClear={handleClear} diff --git a/packages/components/tree/TreeItem.tsx b/packages/components/tree/TreeItem.tsx index fed4f0d1b3..e8f67954a6 100644 --- a/packages/components/tree/TreeItem.tsx +++ b/packages/components/tree/TreeItem.tsx @@ -22,7 +22,7 @@ import useGlobalIcon from '../hooks/useGlobalIcon'; import Checkbox from '../checkbox'; import { useTreeConfig } from './hooks/useTreeConfig'; import useDraggable from './hooks/useDraggable'; -import composeRefs from '../_util/composeRefs'; +import { composeRefs } from '../_util/ref'; import useConfig from '../hooks/useConfig'; import type { CheckboxProps } from '../checkbox' diff --git a/test/snap/__snapshots__/csr.test.jsx.snap b/test/snap/__snapshots__/csr.test.jsx.snap index ad1a25ee89..513ff2a98a 100644 --- a/test/snap/__snapshots__/csr.test.jsx.snap +++ b/test/snap/__snapshots__/csr.test.jsx.snap @@ -138758,7 +138758,7 @@ exports[`csr snapshot test > csr test packages/components/tooltip/_example/base. exports[`csr snapshot test > csr test packages/components/tooltip/_example/duration.tsx 1`] = `