Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 14 additions & 12 deletions packages/components/menu/submenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -214,34 +215,35 @@ 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;
hideTimer.value = null;
}, 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();

// 使用延迟隐藏,避免在子项之间移动时闪烁
Expand Down
202 changes: 202 additions & 0 deletions packages/components/popup/__tests__/popup.test.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>();
return {
attachRef,
popupProps,
};
},
template: `
<Popup v-bind="popupProps" :attach="() => attachRef">
<button id="btn">trigger</button>
</Popup>
<div ref="attachRef"></div>
`,
});
}

function createNestedShadowPopupWrapper(onParentVisibleChange) {
// eslint-disable-next-line vue/one-component-per-file
return defineComponent({
components: { Popup },
setup() {
const attachRef = ref<HTMLElement>();
return {
attachRef,
onParentVisibleChange,
};
},
template: `
<Popup
visible
trigger="hover"
:delay="[0, 0]"
:attach="() => attachRef"
:onVisibleChange="onParentVisibleChange"
>
<button id="parent-trigger">parent trigger</button>
<template #content>
<Popup visible trigger="hover" :delay="[0, 0]" :attach="() => attachRef" content="child content">
<button id="child-trigger">child trigger</button>
</Popup>
</template>
</Popup>
<div ref="attachRef"></div>
`,
});
}

function waitPopupMounted() {
return new Promise((resolve) => {
requestAnimationFrame(() => {
setTimeout(resolve, 0);
});
});
}

describe('Popup', () => {
beforeEach(() => {
// create teleport target
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -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: {
Expand Down
Loading
Loading