diff --git a/packages/components/menu/submenu.tsx b/packages/components/menu/submenu.tsx index 654931aa93..bec064c0a3 100644 --- a/packages/components/menu/submenu.tsx +++ b/packages/components/menu/submenu.tsx @@ -16,6 +16,7 @@ import { } from 'vue'; import { isFunction } from 'lodash-es'; import { useRipple, useContent, useTNodeJSX, usePrefixClass, useCollapseAnimation } from '@tdesign/shared-hooks'; +import { containsWithShadow, getComposedPath, includesNodeInPath } from '@tdesign/shared-utils'; import props from './submenu-props'; import { TdMenuInterface, TdSubMenuInterface, TdMenuItem } from './types'; @@ -214,13 +215,20 @@ export default defineComponent({ }, 0); }; - const targetInPopup = (el: HTMLElement) => el?.classList.contains(`${classPrefix.value}-menu__popup`); + const isTargetWithinPopup = (target?: Node) => { + return containsWithShadow(popupWrapperRef.value, target) || containsWithShadow(subPopupRef.value, target); + }; + + const isEventWithinSubmenu = (event: MouseEvent) => { + const path = getComposedPath(event); + return includesNodeInPath(path, submenuRef.value) || includesNodeInPath(path, popupWrapperRef.value); + }; const handleMouseLeave = (e: MouseEvent) => { clearTimers(); hideTimer.value = setTimeout(() => { - const inPopup = targetInPopup(e.relatedTarget as HTMLElement); + const inPopup = isTargetWithinPopup(e.relatedTarget as Node); if (isCursorInPopup.value || inPopup) return; popupVisible.value = false; @@ -228,20 +236,14 @@ export default defineComponent({ }, 100); }; - const handleMouseLeavePopup = (e: any) => { - const { toElement, relatedTarget } = e; - let target = toElement || relatedTarget; + const handleMouseLeavePopup = (e: MouseEvent) => { + const target = e.relatedTarget as Node; - if (target === subPopupRef.value) return; - - const isSubmenu = (el: Element) => el === submenuRef.value; - while (target !== null && target !== document && !isSubmenu(target)) { - target = target.parentNode; - } + if (target === subPopupRef.value || isTargetWithinPopup(target)) return; isCursorInPopup.value = false; - if (!isSubmenu(target)) { + if (!isEventWithinSubmenu(e) && !containsWithShadow(submenuRef.value, target)) { clearTimers(); // 使用延迟隐藏,避免在子项之间移动时闪烁 diff --git a/packages/components/popup/__tests__/popup.test.tsx b/packages/components/popup/__tests__/popup.test.tsx index 2ff4083da7..67e32a093a 100644 --- a/packages/components/popup/__tests__/popup.test.tsx +++ b/packages/components/popup/__tests__/popup.test.tsx @@ -1,10 +1,71 @@ // @ts-nocheck import { mount } from '@vue/test-utils'; import { describe, it, beforeEach, afterEach, expect } from 'vitest'; +import { defineComponent, nextTick, ref } from 'vue'; import { usePrefixClass } from '@tdesign/shared-hooks'; import Popup from '@tdesign/components/popup'; const POPUPClASS = `.${usePrefixClass('popup').value}`; + +function createShadowPopupWrapper(popupProps = {}) { + // eslint-disable-next-line vue/one-component-per-file + return defineComponent({ + components: { Popup }, + setup() { + const attachRef = ref(); + return { + attachRef, + popupProps, + }; + }, + template: ` + + + +
+ `, + }); +} + +function createNestedShadowPopupWrapper(onParentVisibleChange) { + // eslint-disable-next-line vue/one-component-per-file + return defineComponent({ + components: { Popup }, + setup() { + const attachRef = ref(); + return { + attachRef, + onParentVisibleChange, + }; + }, + template: ` + + + + +
+ `, + }); +} + +function waitPopupMounted() { + return new Promise((resolve) => { + requestAnimationFrame(() => { + setTimeout(resolve, 0); + }); + }); +} + describe('Popup', () => { beforeEach(() => { // create teleport target @@ -280,6 +341,81 @@ describe('Popup', () => { await btn.trigger('focus'); expect(document.querySelector(POPUPClASS)).toBeDefined(); }); + it(':trigger hover works in shadowRoot', async () => { + const host = document.createElement('div'); + const shadowRoot = host.attachShadow({ mode: 'open' }); + const mountNode = document.createElement('div'); + shadowRoot.appendChild(mountNode); + document.body.appendChild(host); + + const wrapper = await mount( + createShadowPopupWrapper({ + content, + trigger: 'hover', + delay: [0, 0], + }), + { + attachTo: mountNode, + global: { + stubs: { teleport: false }, + }, + }, + ); + + const triggerNode = shadowRoot.querySelector('#btn') as HTMLElement; + triggerNode.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true, composed: true })); + await nextTick(); + await new Promise(setTimeout); + + const popup = shadowRoot.querySelector(POPUPClASS) as HTMLElement; + expect(popup).toBeTruthy(); + + popup.dispatchEvent( + new MouseEvent('mouseleave', { + bubbles: true, + composed: true, + relatedTarget: triggerNode, + }), + ); + await new Promise(setTimeout); + + expect(wrapper.emitted()['update:visible']).toBeFalsy(); + }); + it(':trigger hover remains open when moving to a nested popup in shadowRoot', async () => { + const parentVisibleChanges = []; + const host = document.createElement('div'); + const shadowRoot = host.attachShadow({ mode: 'open' }); + const mountNode = document.createElement('div'); + shadowRoot.appendChild(mountNode); + document.body.appendChild(host); + + await mount( + createNestedShadowPopupWrapper((...args) => parentVisibleChanges.push(args)), + { + attachTo: mountNode, + global: { + stubs: { teleport: false }, + }, + }, + ); + + await nextTick(); + await waitPopupMounted(); + await waitPopupMounted(); + const popups = shadowRoot.querySelectorAll(POPUPClASS); + expect(popups).toHaveLength(2); + + popups[0].dispatchEvent( + new MouseEvent('mouseleave', { + bubbles: true, + composed: true, + relatedTarget: popups[1], + }), + ); + await new Promise(setTimeout); + + expect(parentVisibleChanges).toHaveLength(0); + }); /** 是否显示浮层 */ it(':visible', async () => { const wrapper = await mount(Popup, { @@ -321,6 +457,72 @@ describe('Popup', () => { it('onScroll', () => {}); /** 当浮层隐藏或显示时触发,`trigger=document` 表示点击非浮层元素触发;`trigger=context-menu` 表示右击触发 */ it('onVisibleChange', () => {}); + it('outside click works with shadowRoot popup', async () => { + const visibleChanges = []; + const host = document.createElement('div'); + const shadowRoot = host.attachShadow({ mode: 'open' }); + const mountNode = document.createElement('div'); + shadowRoot.appendChild(mountNode); + document.body.appendChild(host); + + await mount( + createShadowPopupWrapper({ + content, + visible: true, + delay: [0, 0], + onVisibleChange: (...args) => visibleChanges.push(args), + }), + { + attachTo: mountNode, + global: { + stubs: { teleport: false }, + }, + }, + ); + + await nextTick(); + await waitPopupMounted(); + const popup = shadowRoot.querySelector(POPUPClASS) as HTMLElement; + expect(popup).toBeTruthy(); + + popup.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, composed: true })); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(visibleChanges).toHaveLength(0); + + document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, composed: true })); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(visibleChanges[0][0]).toEqual(false); + expect(visibleChanges[0][1].trigger).toEqual('document'); + }); + it('clicking a nested popup in shadowRoot does not close its parent', async () => { + const parentVisibleChanges = []; + const host = document.createElement('div'); + const shadowRoot = host.attachShadow({ mode: 'open' }); + const mountNode = document.createElement('div'); + shadowRoot.appendChild(mountNode); + document.body.appendChild(host); + + await mount( + createNestedShadowPopupWrapper((...args) => parentVisibleChanges.push(args)), + { + attachTo: mountNode, + global: { + stubs: { teleport: false }, + }, + }, + ); + + await nextTick(); + await waitPopupMounted(); + await waitPopupMounted(); + const popups = shadowRoot.querySelectorAll(POPUPClASS); + expect(popups).toHaveLength(2); + + popups[1].dispatchEvent(new MouseEvent('mousedown', { bubbles: true, composed: true })); + await new Promise(setTimeout); + + expect(parentVisibleChanges).toHaveLength(0); + }); it('keydown-esc hide popup', async () => { const wrapper = await mount(Popup, { props: { diff --git a/packages/components/popup/popup.tsx b/packages/components/popup/popup.tsx index e81b5663d8..c75fcc722f 100644 --- a/packages/components/popup/popup.tsx +++ b/packages/components/popup/popup.tsx @@ -17,50 +17,63 @@ import { } from 'vue'; import { useVModel, useContent, useTNodeJSX, usePrefixClass, useCommonClassName } from '@tdesign/shared-hooks'; -import { off, on, once, isServer } from '@tdesign/shared-utils'; +import { + off, + on, + once, + isServer, + containsWithShadow, + getComposedPath, + includesNodeInPath, +} from '@tdesign/shared-utils'; import setStyle from '@tdesign/common-js/utils/setStyle'; import Container from './container'; import props from './props'; import { PopupTriggerEvent, TdPopupProps } from './type'; -const POPUP_ATTR_NAME = 'data-td-popup'; -const POPUP_PARENT_ATTR_NAME = 'data-td-popup-parent'; - function isEscapeKey(ev: KeyboardEvent) { return ev.key === 'Escape' || ev.code === 'Escape' || ev.keyCode === 27; } -/** - * @param id - * @param upwards query upwards poppers - */ -function getPopperTree(id: number | string, upwards?: boolean): Element[] { - const list = [] as any; - const selectors = [POPUP_PARENT_ATTR_NAME, POPUP_ATTR_NAME]; - - if (!id) return list; - if (upwards) { - selectors.unshift(selectors.pop()); - } - - recurse(id); - - return list; - - function recurse(id: number | string) { - const children = document.querySelectorAll(`[${selectors[0]}="${id}"]`); - children.forEach((el) => { - list.push(el); - const childId = el.getAttribute(selectors[1]); - if (childId && childId !== id) { - recurse(childId); - } - }); - } +type PopupId = symbol; + +type PopupRegistryEntry = { + id: PopupId; + parentId?: PopupId; + element?: HTMLElement; +}; + +const popupRegistry = new Map(); + +function registerPopup(entry: PopupRegistryEntry) { + popupRegistry.set(entry.id, entry); +} + +function unregisterPopup(id: PopupId) { + popupRegistry.delete(id); +} + +function updatePopupElement(id: PopupId, element?: HTMLElement) { + const entry = popupRegistry.get(id); + if (!entry) return; + entry.element = element; +} + +function getPopupDescendants(id: PopupId): HTMLElement[] { + const descendants: HTMLElement[] = []; + + popupRegistry.forEach((entry) => { + if (entry.parentId === id && entry.element) { + descendants.push(entry.element); + descendants.push(...getPopupDescendants(entry.id)); + } + }); + + return descendants; } const parentKey = Symbol() as InjectionKey<{ - id: string; + id: PopupId; assertMouseLeave: (ev: MouseEvent) => void; }>; @@ -119,9 +132,11 @@ export default defineComponent({ const arrowStyle = ref({}); - const id = Date.now().toString(36); + const id = Symbol('popup'); const parent = inject(parentKey, undefined); + registerPopup({ id, parentId: parent?.id }); + provide(parentKey, { id, assertMouseLeave: onMouseLeave, @@ -241,6 +256,7 @@ export default defineComponent({ ); onUnmounted(() => { + unregisterPopup(id); destroyPopper(); clearAllTimeout(); off(document, 'mousedown', onDocumentMouseDown, true); @@ -436,21 +452,23 @@ export default defineComponent({ } function onDocumentMouseDown(ev: MouseEvent) { + const eventPath = getComposedPath(ev); + // click content - if (popperEl.value?.contains(ev.target as Node)) { + if (includesNodeInPath(eventPath, popperEl.value) || containsWithShadow(popperEl.value, ev.target as Node)) { return; } // click trigger element - if (triggerEl.value?.contains(ev.target as Node)) { + if (includesNodeInPath(eventPath, triggerEl.value) || containsWithShadow(triggerEl.value, ev.target as Node)) { return; } // ignore upwards - const activedPopper = getPopperTree(id).find((el) => el.contains(ev.target as Node)); if ( - activedPopper && - getPopperTree(activedPopper.getAttribute(POPUP_PARENT_ATTR_NAME), true).some((el) => el === popperEl.value) + getPopupDescendants(id).some( + (el) => includesNodeInPath(eventPath, el) || containsWithShadow(el, ev.target as Node), + ) ) { return; } @@ -460,9 +478,19 @@ export default defineComponent({ function onMouseLeave(ev: MouseEvent) { isOverlayHover.value = false; - if (props.trigger !== 'hover' || triggerEl.value.contains(ev.target as Node)) return; + if (props.trigger !== 'hover') return; + + const relatedTarget = ev.relatedTarget as Node; + const descendants = getPopupDescendants(id); + if ( + containsWithShadow(triggerEl.value, relatedTarget) || + containsWithShadow(popperEl.value, relatedTarget) || + descendants.some((el) => containsWithShadow(el, relatedTarget)) + ) { + return; + } - const isCursorOverlaps = getPopperTree(id).some((el) => { + const isCursorOverlaps = [popperEl.value, ...descendants].filter(Boolean).some((el) => { const rect = el.getBoundingClientRect(); return ev.x > rect.x && ev.x < rect.x + rect.width && ev.y > rect.y && ev.y < rect.y + rect.height; @@ -507,12 +535,11 @@ export default defineComponent({ const overlay = visible.value || !props.destroyOnClose ? (
(popperEl.value = ref)} + ref={(ref: HTMLElement) => { + popperEl.value = ref; + updatePopupElement(id, ref); + }} style={[{ zIndex: props.zIndex }, getOverlayStyle(), hidePopup && { visibility: 'hidden' }]} v-show={visible.value} onClick={onOverlayClick} diff --git a/packages/shared/utils/dom.ts b/packages/shared/utils/dom.ts index 26fb6bff09..114e9f94c1 100644 --- a/packages/shared/utils/dom.ts +++ b/packages/shared/utils/dom.ts @@ -131,6 +131,48 @@ export const getAttach = (node: any, triggerNode?: any): HTMLElement | Element = return document.body; }; +export function getComposedPath(event?: Event): EventTarget[] { + if (!event) return []; + if (typeof event.composedPath === 'function') { + return event.composedPath(); + } + + const path: EventTarget[] = []; + let current = event.target as Node; + while (current) { + path.push(current); + current = + (current as Node & { parentNode?: Node; host?: Node }).parentNode || + (current as Node & { host?: Node }).host || + null; + } + path.push(window); + return path; +} + +export function includesNodeInPath(path: EventTarget[], node?: EventTarget | null): boolean { + if (!node) return false; + return path.includes(node); +} + +export function containsWithShadow(root?: Node | null, target?: Node | null): boolean { + if (!root || !target) return false; + if (root === target) return true; + if (root.contains(target)) return true; + + let current = target; + while (current) { + const parent = + (current as Node & { parentNode?: Node; host?: Node }).parentNode || + (current as Node & { host?: Node }).host || + null; + if (parent === root) return true; + current = parent; + } + + return false; +} + /** * 获取滚动容器 * 因为document不存在scroll等属性, 因此排除document