From 9033d19f7650e84d94cd39ac8c899cc5c55466a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?w=C5=AB=20y=C4=81ng?= Date: Fri, 23 Jan 2026 14:33:07 +0800 Subject: [PATCH 1/5] fix(popup): fix close logic of nested popup --- packages/components/popup/Popup.tsx | 5 ++++- packages/components/popup/hooks/useTrigger.tsx | 17 ++++++++++++++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/packages/components/popup/Popup.tsx b/packages/components/popup/Popup.tsx index 595acdec43..dc295b3ab4 100644 --- a/packages/components/popup/Popup.tsx +++ b/packages/components/popup/Popup.tsx @@ -112,6 +112,7 @@ const Popup = forwardRef((originalProps, ref const { triggerElementIsString, getTriggerElement, getTriggerNode, getPopupProps } = useTrigger({ triggerElement, + popupElement, content, disabled, trigger, @@ -225,7 +226,9 @@ const Popup = forwardRef((originalProps, ref function handleExited() { setIsOverlayHover(false); - !destroyOnClose && popupElement && (popupElement.style.display = 'none'); + if (!destroyOnClose && popupElement) popupElement.style.display = 'none'; + // 如果是 destroyOnClose 需要重置 popupElement 否则影响二次操作的判断 + else setPopupElement(null); } function handleEnter() { setIsOverlayHover(true); diff --git a/packages/components/popup/hooks/useTrigger.tsx b/packages/components/popup/hooks/useTrigger.tsx index 95b0f2bb13..bfdb381c5f 100644 --- a/packages/components/popup/hooks/useTrigger.tsx +++ b/packages/components/popup/hooks/useTrigger.tsx @@ -13,7 +13,16 @@ const isEventFromDisabledElement = (e: Event | React.SyntheticEvent, container: return !!(disabledEl && container.contains(disabledEl)); }; -export default function useTrigger({ triggerElement, content, disabled, trigger, visible, onVisibleChange, delay }) { +export default function useTrigger({ + triggerElement, + content, + disabled, + trigger, + visible, + onVisibleChange, + delay, + popupElement, +}) { const { classPrefix } = useConfig(); const triggerElementIsString = typeof triggerElement === 'string'; @@ -53,8 +62,10 @@ export default function useTrigger({ triggerElement, content, disabled, trigger, const handleMouseLeave = (e: MouseEvent | React.MouseEvent) => { if (trigger !== 'hover' || hasPopupMouseDown.current) return; const relatedTarget = e.relatedTarget as HTMLElement; - const isMovingToContent = relatedTarget?.closest?.(`.${classPrefix}-popup`); - if (isMovingToContent) return; + const closestPopup = relatedTarget?.closest(`.${classPrefix}-popup`); + + const isMovingToCurrentPopup = popupElement ? popupElement?.isEqualNode(closestPopup) : closestPopup; + if (isMovingToCurrentPopup) return; callFuncWithDelay({ delay: exitDelay, callback: () => onVisibleChange(false, { e, trigger: 'trigger-element-hover' }), From 8397e8974fa4a1e8c984309f1b7b62184819008f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?w=C5=AB=20y=C4=81ng?= Date: Fri, 23 Jan 2026 14:59:09 +0800 Subject: [PATCH 2/5] chore: optimize --- packages/components/popup/hooks/useTrigger.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/components/popup/hooks/useTrigger.tsx b/packages/components/popup/hooks/useTrigger.tsx index bfdb381c5f..e9152fb4b2 100644 --- a/packages/components/popup/hooks/useTrigger.tsx +++ b/packages/components/popup/hooks/useTrigger.tsx @@ -62,9 +62,9 @@ export default function useTrigger({ const handleMouseLeave = (e: MouseEvent | React.MouseEvent) => { if (trigger !== 'hover' || hasPopupMouseDown.current) return; const relatedTarget = e.relatedTarget as HTMLElement; - const closestPopup = relatedTarget?.closest(`.${classPrefix}-popup`); + const closestPopup = relatedTarget?.closest?.(`.${classPrefix}-popup`); - const isMovingToCurrentPopup = popupElement ? popupElement?.isEqualNode(closestPopup) : closestPopup; + const isMovingToCurrentPopup = popupElement ? popupElement?.isEqualNode?.(closestPopup) : closestPopup; if (isMovingToCurrentPopup) return; callFuncWithDelay({ delay: exitDelay, From 35c2eac1fc415531b58c81a7c81423d89d8c2144 Mon Sep 17 00:00:00 2001 From: Rylan Date: Fri, 23 Jan 2026 15:23:41 +0800 Subject: [PATCH 3/5] test(Popup): add hover test --- .../components/popup/__tests__/popup.test.tsx | 160 ++++++++++++------ 1 file changed, 104 insertions(+), 56 deletions(-) diff --git a/packages/components/popup/__tests__/popup.test.tsx b/packages/components/popup/__tests__/popup.test.tsx index 260f7362b3..0b29d8a2b7 100644 --- a/packages/components/popup/__tests__/popup.test.tsx +++ b/packages/components/popup/__tests__/popup.test.tsx @@ -264,62 +264,6 @@ describe('Popup 组件测试', () => { expect(popupContainer).toBeNull(); }); - test('测试浮层嵌套', async () => { - const wrappedTriggerElement = '嵌套触发元素'; - const wrappedPopupTestId = 'wrapped-popup-test-id'; - const wrappedPopupText = '嵌套弹出层内容'; - const { getByText, queryByTestId } = render( - {wrappedPopupText}} - > -
{wrappedTriggerElement}
-
- } - > - {triggerElement} - , - ); - - // 初始时,所有浮层都不存在 - const popupElement1 = await waitFor(() => queryByTestId(popupTestId)); - const wrappedPopupElement1 = await waitFor(() => queryByTestId(wrappedPopupTestId)); - expect(popupElement1).toBeNull(); - expect(wrappedPopupElement1).toBeNull(); - - // 触发浮层和嵌套浮层 - act(() => { - fireEvent.click(getByText(triggerElement)); - }); - act(() => { - fireEvent.click(getByText(wrappedTriggerElement)); - }); - - // 所有浮层都展示出来 - const popupElement2 = await waitFor(() => queryByTestId(popupTestId)); - const wrappedPopupElement2 = await waitFor(() => queryByTestId(wrappedPopupTestId)); - expect(popupElement2).not.toBeNull(); - expect(wrappedPopupElement2).not.toBeNull(); - - // 嵌套元素的浮层触发 mouseDown,不应该关闭任何浮层 - act(() => { - fireEvent.mouseDown(queryByTestId(wrappedPopupTestId)); - }); - - // 所有浮层都展示出来 - const popupElement3 = await waitFor(() => queryByTestId(popupTestId)); - const wrappedPopupElement3 = await waitFor(() => queryByTestId(wrappedPopupTestId)); - expect(popupElement3).not.toBeNull(); - expect(wrappedPopupElement3).not.toBeNull(); - }); - test('异常情况:浮层隐藏时点击其他地方,浮层不可以展示出来', async () => { const testClassName = 'test-class-name'; render( @@ -395,3 +339,107 @@ describe('Popup 组件测试', () => { }); }); }); + +describe('Popup 嵌套组件测试', () => { + const popupTestId = 'popup-test-id'; + const wrappedPopupTestId = 'wrapped-popup-test-id'; + const triggerElement = '外层触发元素'; + const wrappedTriggerElement = '内层触发元素'; + const wrappedPopupText = '内层浮层内容'; + + const renderNestedPopup = (trigger: 'click' | 'hover') => + render( + {wrappedPopupText}} + > +
{wrappedTriggerElement}
+
+ } + > + {triggerElement} + , + ); + + test('trigger="click"', async () => { + const { getByText, queryByTestId } = renderNestedPopup('click'); + + // 初始状态,浮层不存在 + expect(queryByTestId(popupTestId)).toBeNull(); + expect(queryByTestId(wrappedPopupTestId)).toBeNull(); + + // click 外层触发器 + act(() => { + fireEvent.click(getByText(triggerElement)); + }); + const popupElement = await waitFor(() => queryByTestId(popupTestId)); + expect(popupElement).not.toBeNull(); + + // click 内层触发器 + act(() => { + fireEvent.click(getByText(wrappedTriggerElement)); + }); + const wrappedPopupElement = await waitFor(() => queryByTestId(wrappedPopupTestId)); + expect(wrappedPopupElement).not.toBeNull(); + expect(wrappedPopupElement).toHaveTextContent(wrappedPopupText); + + // mouseDown 内层内容不关闭 + act(() => { + fireEvent.mouseDown(queryByTestId(wrappedPopupTestId) as HTMLElement); + }); + await waitFor(() => { + expect(popupElement).not.toBeNull(); + expect(wrappedPopupElement).not.toBeNull(); + }); + }); + + test('trigger="hover"', async () => { + const { getByText, getByTestId, queryByTestId } = renderNestedPopup('hover'); + + // 初始状态,浮层不存在 + expect(queryByTestId(popupTestId)).toBeNull(); + expect(queryByTestId(wrappedPopupTestId)).toBeNull(); + + // hover 外层触发器 + act(() => { + fireEvent.mouseEnter(getByText(triggerElement)); + }); + const popupElement = await waitFor(() => queryByTestId(popupTestId)); + expect(popupElement).not.toBeNull(); + + // hover 内层触发器 + act(() => { + fireEvent.mouseEnter(getByTestId(popupTestId)); + }); + const wrappedPopupElement = await waitFor(() => queryByTestId(wrappedPopupTestId)); + expect(wrappedPopupElement).not.toBeNull(); + expect(wrappedPopupElement).toHaveTextContent(wrappedPopupText); + + // mouseLeave 内层触发器 + act(() => { + fireEvent.mouseLeave(getByTestId(popupTestId)); + }); + + // 等待内层浮层销毁 + await waitFor(() => { + expect(queryByTestId(wrappedPopupTestId)).toBeNull(); + }); + + // mouseLeave 外层触发器 + act(() => { + fireEvent.mouseLeave(getByText(triggerElement)); + }); + + // 等待外层浮层销毁 + await waitFor(() => { + expect(queryByTestId(popupTestId)).toBeNull(); + }); + }); +}); From 7ec857d4fe8977d048f62afe60c9a14886e0fa4b Mon Sep 17 00:00:00 2001 From: Rylan Date: Fri, 23 Jan 2026 15:49:43 +0800 Subject: [PATCH 4/5] chore: remove useless code --- .../components/popup/hooks/useTrigger.tsx | 22 ------------------- 1 file changed, 22 deletions(-) diff --git a/packages/components/popup/hooks/useTrigger.tsx b/packages/components/popup/hooks/useTrigger.tsx index e9152fb4b2..dc3c7249b7 100644 --- a/packages/components/popup/hooks/useTrigger.tsx +++ b/packages/components/popup/hooks/useTrigger.tsx @@ -3,7 +3,6 @@ import { canUseDocument } from '../../_util/dom'; import { off, on } from '../../_util/listener'; import { composeRefs, getNodeRef, getRefDom, supportNodeRef } from '../../_util/ref'; import useConfig from '../../hooks/useConfig'; -import useResizeObserver from '../../hooks/useResizeObserver'; const ESC_KEY = 'Escape'; @@ -227,27 +226,6 @@ export default function useTrigger({ }; }, [visible, classPrefix, getTriggerElement]); - useResizeObserver( - triggerRef, - (entries) => { - entries.forEach((entry) => { - // 嵌套使用 - // 针对父 Popup 关闭时,trigger 隐藏的场景 - if (entry.contentRect.width === 0 && entry.contentRect.height === 0) { - const element = entry.target as HTMLElement; - // 检查元素是否真的被隐藏(完全通过判断尺寸为 0x0,会误判 inline 元素) - const computedStyle = window.getComputedStyle(element); - const isHidden = - computedStyle.display === 'none' || computedStyle.visibility === 'hidden' || computedStyle.opacity === '0'; - if (isHidden) { - onVisibleChange(false, { trigger: 'document' }); - } - } - }); - }, - visible && shouldToggle, - ); - function getTriggerNode(children: React.ReactNode) { if (triggerElementIsString) return; From 7b05e973a20c881e948ff4d504f7dad2d35ca688 Mon Sep 17 00:00:00 2001 From: tdesign-bot Date: Fri, 23 Jan 2026 09:49:25 +0000 Subject: [PATCH 5/5] chore: stash changelog [ci skip] --- packages/tdesign-react/.changelog/pr-4099.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 packages/tdesign-react/.changelog/pr-4099.md diff --git a/packages/tdesign-react/.changelog/pr-4099.md b/packages/tdesign-react/.changelog/pr-4099.md new file mode 100644 index 0000000000..dd4526f2b3 --- /dev/null +++ b/packages/tdesign-react/.changelog/pr-4099.md @@ -0,0 +1,7 @@ +--- +pr_number: 4099 +contributor: uyarn +--- + +- fix(Popup): 修复组件嵌套使用时的关闭逻辑 @uyarn ([#4099](https://github.com/Tencent/tdesign-react/pull/4099)) +- chore(Popup): 优化开启 `destroyOnClose` 时的内部状态,确保逻辑正常 @uyarn ([#4099](https://github.com/Tencent/tdesign-react/pull/4099))