diff --git a/packages/components/cascader/Cascader.tsx b/packages/components/cascader/Cascader.tsx index 38f226427f..4dbfd45eaa 100644 --- a/packages/components/cascader/Cascader.tsx +++ b/packages/components/cascader/Cascader.tsx @@ -93,9 +93,9 @@ const Cascader: React.FC = (originalProps) => { const { setVisible, visible, inputVal, setInputVal } = cascaderContext; const updateScrollTop = (content: HTMLDivElement) => { - const cascaderMenuList = content.querySelectorAll(`.${COMPONENT_NAME}__menu`); + const cascaderMenuList = content?.querySelectorAll(`.${COMPONENT_NAME}__menu`); requestAnimationFrame(() => { - cascaderMenuList.forEach((menu: HTMLDivElement) => { + cascaderMenuList?.forEach((menu: HTMLDivElement) => { const firstSelectedNode: HTMLDivElement = menu?.querySelector(`.${classPrefix}-is-selected`) || menu?.querySelector(`.${classPrefix}-is-expanded`); if (!firstSelectedNode || !menu) return; diff --git a/packages/components/cascader/__tests__/cascader.test.tsx b/packages/components/cascader/__tests__/cascader.test.tsx index ec647569fb..9243074511 100644 --- a/packages/components/cascader/__tests__/cascader.test.tsx +++ b/packages/components/cascader/__tests__/cascader.test.tsx @@ -123,7 +123,7 @@ describe('Cascader 组件测试', () => { const spy = vi.spyOn(selectInputProps, 'onInputChange'); render(); // 模拟用户键盘输入 "test" ,一共会触发四次 onInputChange - userEvent.type(document.querySelector('input'), enterText); + await userEvent.type(document.querySelector('input'), enterText); await mockTimeout(() => expect(spy).toHaveBeenCalledTimes(enterText.length)); }); @@ -244,17 +244,17 @@ describe('Cascader 组件测试', () => { ); // 搜索 子选项一 ,共有两个结果,成功匹配的内容应该高亮 fireEvent.focus(getByPlaceholderText(placeholder)); - userEvent.type(getByPlaceholderText(placeholder), filterContent); + await userEvent.type(getByPlaceholderText(placeholder), filterContent); await mockTimeout(() => expect(document.querySelector(popupSelector).querySelectorAll('.t-cascader__item-label--filter').length).toBe(2), ); // 清空搜索项,无匹配任何高亮内容 - userEvent.type(getByPlaceholderText(placeholder), '{backspace}{backspace}{backspace}{backspace}'); + await userEvent.type(getByPlaceholderText(placeholder), '{backspace}{backspace}{backspace}{backspace}'); await mockTimeout(() => expect(document.querySelector(popupSelector).querySelectorAll('.t-cascader__item-label--filter').length).toBe(0), ); // 匹配不到任何内容 - userEvent.type(getByPlaceholderText(placeholder), 'null'); + await userEvent.type(getByPlaceholderText(placeholder), 'null'); await mockTimeout(() => expect(getByText('暂无数据')).toBeInTheDocument()); }); diff --git a/packages/components/common/Portal.tsx b/packages/components/common/Portal.tsx index 71d8270b76..8147c6de49 100644 --- a/packages/components/common/Portal.tsx +++ b/packages/components/common/Portal.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, useMemo, useImperativeHandle } from 'react'; +import React, { forwardRef, useImperativeHandle, useState, useEffect } from 'react'; import { createPortal } from 'react-dom'; import { AttachNode, AttachNodeReturnValue } from '../common'; import { canUseDocument } from '../_util/dom'; @@ -40,26 +40,33 @@ export function getAttach(attach: PortalProps['attach'], triggerNode?: HTMLEleme const Portal = forwardRef((props: PortalProps, ref) => { const { attach, children, triggerNode } = props; const { classPrefix } = useConfig(); - - const container = useMemo(() => { + const [container] = useState(() => { if (!canUseDocument) return null; const el = document.createElement('div'); el.className = `${classPrefix}-portal-wrapper`; return el; - }, [classPrefix]); + }); + const [mounted, setMounted] = useState(false); useIsomorphicLayoutEffect(() => { + if (!mounted) return; + const parentElement = getAttach(attach, triggerNode); parentElement?.appendChild?.(container); - return () => { parentElement?.removeChild?.(container); }; - }, [container, attach, triggerNode]); + }, [container, attach, triggerNode, mounted]); + + useEffect(() => { + if (!mounted) { + setMounted(true); + } + }, [mounted]); useImperativeHandle(ref, () => container); - return canUseDocument ? createPortal(children, container) : null; + return canUseDocument && mounted ? createPortal(children, container) : null; }); Portal.displayName = 'Portal'; diff --git a/packages/components/date-picker/base/Header.tsx b/packages/components/date-picker/base/Header.tsx index 54f9a2b127..7b5588f291 100644 --- a/packages/components/date-picker/base/Header.tsx +++ b/packages/components/date-picker/base/Header.tsx @@ -161,7 +161,7 @@ const DatePickerHeader = (props: DatePickerHeaderProps) => { // eslint-disable-next-line no-param-reassign content.scrollTop = content.scrollHeight - 30 * 10; } else { - const firstSelectedNode: HTMLDivElement = content.querySelector(`.${classPrefix}-is-selected`); + const firstSelectedNode: HTMLDivElement = content?.querySelector(`.${classPrefix}-is-selected`); if (firstSelectedNode) { const { paddingBottom } = getComputedStyle(firstSelectedNode); diff --git a/packages/components/dialog/Dialog.tsx b/packages/components/dialog/Dialog.tsx index 43eb7a3fd4..4ce646f4c8 100644 --- a/packages/components/dialog/Dialog.tsx +++ b/packages/components/dialog/Dialog.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, useEffect, useRef, useImperativeHandle } from 'react'; +import React, { forwardRef, useEffect, useRef, useImperativeHandle, useState } from 'react'; import { CSSTransition } from 'react-transition-group'; import classNames from 'classnames'; import log from '@tdesign/common-js/log/index'; @@ -13,11 +13,33 @@ import { dialogDefaultProps } from './defaultProps'; import DialogCard from './DialogCard'; import useDialogEsc from './hooks/useDialogEsc'; import useLockStyle from './hooks/useLockStyle'; -import useDialogPosition from './hooks/useDialogPosition'; import useDialogDrag from './hooks/useDialogDrag'; import { parseValueToPx } from './utils'; import useDefaultProps from '../hooks/useDefaultProps'; import useAttach from '../hooks/useAttach'; +import { canUseDocument } from '../_util/dom'; + +type MousePosition = { x: number; y: number } | null; + +let mousePosition: MousePosition; + +const getClickPosition = (e: MouseEvent) => { + mousePosition = { + x: e.pageX, + y: e.pageY, + }; + // 100ms 内发生过点击事件,则从点击位置动画展示 + // 否则直接 zoom 展示 + // 这样可以兼容非点击方式展开 + setTimeout(() => { + mousePosition = null; + }, 100); +}; + +// 只有点击事件支持从鼠标位置动画展开 +if (canUseDocument) { + document.documentElement.addEventListener('click', getClickPosition, true); +} export interface DialogProps extends TdDialogProps, StyledProps { isPlugin?: boolean; // 是否以插件形式调用 @@ -72,10 +94,12 @@ const Dialog = forwardRef((originalProps, ref) => { } = state; const dialogAttach = useAttach('dialog', attach); + const [animationVisible, setAnimationVisible] = useState(visible); + const [dialogAnimationVisible, setDialogAnimationVisible] = useState(false); useLockStyle({ preventScrollThrough, visible, mode, showInAttachedElement }); useDialogEsc(visible, wrapRef); - useDialogPosition(visible, dialogCardRef); + const { onDialogMoveStart } = useDialogDrag({ dialogCardRef, contentClickRef, @@ -90,6 +114,18 @@ const Dialog = forwardRef((originalProps, ref) => { setState((prevState) => ({ ...prevState, ...props })); }, [props, setState, isPlugin]); + useEffect(() => { + if (dialogAnimationVisible) { + wrapRef.current?.focus(); + if (mousePosition && dialogCardRef.current) { + const offsetX = mousePosition.x - dialogCardRef.current.offsetLeft; + const offsetY = mousePosition.y - dialogCardRef.current.offsetTop; + + dialogCardRef.current.style.transformOrigin = `${offsetX}px ${offsetY}px`; + } + } + }, [dialogAnimationVisible]); + useImperativeHandle(ref, () => ({ show() { setState({ visible: true }); @@ -151,25 +187,30 @@ const Dialog = forwardRef((originalProps, ref) => { } }; - const onAnimateLeave = () => { - onClosed?.(); - + // Portal Animation + const onAnimateStart = () => { + onBeforeOpen?.(); + setAnimationVisible(true); if (!wrapRef.current) return; - wrapRef.current.style.display = 'none'; + wrapRef.current.style.display = 'block'; }; - const onAnimateStart = () => { + const onAnimateLeave = () => { + onClosed?.(); + setAnimationVisible(false); if (!wrapRef.current) return; - onBeforeOpen?.(); - wrapRef.current.style.display = 'block'; + wrapRef.current.style.display = 'none'; }; + // Dialog Animation const onInnerAnimateStart = () => { + setDialogAnimationVisible(true); if (!dialogCardRef.current) return; dialogCardRef.current.style.display = 'block'; }; const onInnerAnimateLeave = () => { + setDialogAnimationVisible(false); if (!dialogCardRef.current) return; dialogCardRef.current.style.display = 'none'; }; @@ -191,6 +232,7 @@ const Dialog = forwardRef((originalProps, ref) => { ) : null; }; + return ( ((originalProps, ref) => { nodeRef={portalRef} onEnter={onAnimateStart} onEntered={onOpened} - onExit={() => onBeforeClose?.()} + onExit={onBeforeClose} onExited={onAnimateLeave} > @@ -211,7 +253,7 @@ const Dialog = forwardRef((originalProps, ref) => { [`${componentCls}__ctx--fixed`]: !showInAttachedElement, [`${componentCls}__ctx--absolute`]: showInAttachedElement, })} - style={{ zIndex, display: 'none' }} + style={{ zIndex, display: animationVisible ? undefined : 'none' }} onKeyDown={handleKeyDown} tabIndex={0} > diff --git a/packages/components/dialog/__tests__/dialog.test.tsx b/packages/components/dialog/__tests__/dialog.test.tsx index 0c5bc08632..33bb703be7 100644 --- a/packages/components/dialog/__tests__/dialog.test.tsx +++ b/packages/components/dialog/__tests__/dialog.test.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { render, fireEvent, mockTimeout } from '@test/utils'; +import { render, fireEvent, mockTimeout, vi } from '@test/utils'; import userEvent from '@testing-library/user-event'; import Dialog from '../index'; import { DialogPlugin } from '../plugin'; @@ -22,6 +22,7 @@ function DialogDemo(props) { return ( <>
Open Dialog Modal
+
Close Dialog Modal
{ }); test('EscCloseDialog', async () => { - const { getByText } = render(); - fireEvent.click(getByText('Open Dialog Modal')); + const onEscKeydown = vi.fn(); + const { getByText } = render(); + + await fireEvent.click(getByText('Open Dialog Modal')); expect(document.querySelector('.t-dialog__modal')).toBeInTheDocument(); await user.keyboard('{Escape}'); - await mockTimeout(() => expect(document.querySelector('.t-dialog__modal')).not.toBeInTheDocument(), 400); + expect(onEscKeydown).toHaveBeenCalled(); }); test('EnterConfirm', async () => { - const { getByText } = render(); + const onConfirm = vi.fn(); + const { getByText } = render(); + + expect(document.querySelector('.t-dialog__modal')).not.toBeInTheDocument(); + fireEvent.click(getByText('Open Dialog Modal')); expect(document.querySelector('.t-dialog__modal')).toBeInTheDocument(); await user.keyboard('{Enter}'); - await mockTimeout(() => expect(document.querySelector('.t-dialog__modal')).not.toBeInTheDocument(), 400); + expect(onConfirm).toHaveBeenCalled(); }); test('DraggableDialog', () => { diff --git a/packages/components/popup/Popup.tsx b/packages/components/popup/Popup.tsx index 993106979f..ac77ed70b4 100644 --- a/packages/components/popup/Popup.tsx +++ b/packages/components/popup/Popup.tsx @@ -147,10 +147,10 @@ const Popup = forwardRef((originalProps, ref) => { // 下拉展开时更新内部滚动条 useEffect(() => { if (!triggerRef.current) triggerRef.current = getTriggerDom(); - if (visible) { + if (visible && popupElement) { updateScrollTop?.(contentRef.current); } - }, [visible, updateScrollTop, getTriggerDom]); + }, [visible, popupElement, updateScrollTop, getTriggerDom]); function handleExited() { !destroyOnClose && popupElement && (popupElement.style.display = 'none'); diff --git a/packages/components/textarea/Textarea.tsx b/packages/components/textarea/Textarea.tsx index 2b338f6e55..56abf97bd5 100644 --- a/packages/components/textarea/Textarea.tsx +++ b/packages/components/textarea/Textarea.tsx @@ -161,8 +161,7 @@ const Textarea = forwardRef((originalProps, useEffect(() => { handleAutoFocus(); - adjustTextareaHeight(); - }, [handleAutoFocus, adjustTextareaHeight]); + }, [handleAutoFocus]); useEffect(() => { if (allowInputOverMax) { diff --git a/test/snap/__snapshots__/csr.test.jsx.snap b/test/snap/__snapshots__/csr.test.jsx.snap index 1b35e1ba31..c6ef3316b9 100644 --- a/test/snap/__snapshots__/csr.test.jsx.snap +++ b/test/snap/__snapshots__/csr.test.jsx.snap @@ -140298,7 +140298,7 @@ exports[`ssr snapshot test > ssr test packages/components/table/_example/fixed-h exports[`ssr snapshot test > ssr test packages/components/table/_example/lazy.tsx 1`] = `"
申请人
申请状态
申请事项
邮箱地址
申请时间
"`; -exports[`ssr snapshot test > ssr test packages/components/table/_example/loading.tsx 1`] = `"
集群名称
状态
管理员
描述
集群名称
状态
管理员
描述
自定义加载状态文本
集群名称
状态
管理员
描述
  渲染函数自定义加载中(可单独去除内置加载图标)
"`; +exports[`ssr snapshot test > ssr test packages/components/table/_example/loading.tsx 1`] = `"
集群名称
状态
管理员
描述
集群名称
状态
管理员
描述
集群名称
状态
管理员
描述
"`; exports[`ssr snapshot test > ssr test packages/components/table/_example/merge-cells.tsx 1`] = `"
申请人
申请状态
审批事项
邮箱地址
其他信息
"`; diff --git a/test/snap/__snapshots__/ssr.test.jsx.snap b/test/snap/__snapshots__/ssr.test.jsx.snap index bd46efb9dd..22dcccd2d4 100644 --- a/test/snap/__snapshots__/ssr.test.jsx.snap +++ b/test/snap/__snapshots__/ssr.test.jsx.snap @@ -1024,7 +1024,7 @@ exports[`ssr snapshot test > ssr test packages/components/table/_example/fixed-h exports[`ssr snapshot test > ssr test packages/components/table/_example/lazy.tsx 1`] = `"
申请人
申请状态
申请事项
邮箱地址
申请时间
"`; -exports[`ssr snapshot test > ssr test packages/components/table/_example/loading.tsx 1`] = `"
集群名称
状态
管理员
描述
集群名称
状态
管理员
描述
自定义加载状态文本
集群名称
状态
管理员
描述
  渲染函数自定义加载中(可单独去除内置加载图标)
"`; +exports[`ssr snapshot test > ssr test packages/components/table/_example/loading.tsx 1`] = `"
集群名称
状态
管理员
描述
集群名称
状态
管理员
描述
集群名称
状态
管理员
描述
"`; exports[`ssr snapshot test > ssr test packages/components/table/_example/merge-cells.tsx 1`] = `"
申请人
申请状态
审批事项
邮箱地址
其他信息
"`;