Skip to content

Commit d72cb32

Browse files
authored
fix: ShadowDOM click interactive (#347)
* fix: shadow inner check * chore: warning info * test: add test case
1 parent c88b53e commit d72cb32

File tree

5 files changed

+217
-2
lines changed

5 files changed

+217
-2
lines changed

docs/demos/shadow.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
title: Shadow
3+
nav:
4+
title: Demo
5+
path: /demo
6+
---
7+
8+
<code src="../examples/shadow.tsx"></code>

docs/examples/shadow.tsx

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/* eslint no-console:0 */
2+
import Trigger from 'rc-trigger';
3+
import React from 'react';
4+
import { createRoot } from 'react-dom/client';
5+
import '../../assets/index.less';
6+
7+
const Demo = () => {
8+
return (
9+
<React.StrictMode>
10+
<Trigger
11+
arrow
12+
// forceRender
13+
action="click"
14+
popup={
15+
<div
16+
style={{
17+
background: 'yellow',
18+
border: '1px solid blue',
19+
width: 200,
20+
height: 60,
21+
opacity: 0.9,
22+
}}
23+
>
24+
Popup
25+
</div>
26+
}
27+
popupStyle={{ boxShadow: '0 0 5px red', position: 'absolute' }}
28+
getPopupContainer={(item) => item.parentElement!}
29+
popupAlign={{
30+
points: ['bc', 'tc'],
31+
overflow: {
32+
shiftX: 50,
33+
adjustY: true,
34+
},
35+
offset: [0, -10],
36+
}}
37+
stretch="minWidth"
38+
autoDestroy
39+
>
40+
<span
41+
style={{
42+
background: 'green',
43+
color: '#FFF',
44+
paddingBlock: 30,
45+
paddingInline: 70,
46+
opacity: 0.9,
47+
display: 'inline-block',
48+
marginLeft: 500,
49+
marginTop: 200,
50+
}}
51+
>
52+
Target
53+
</span>
54+
</Trigger>
55+
</React.StrictMode>
56+
);
57+
};
58+
59+
export default () => {
60+
React.useEffect(() => {
61+
const host = document.createElement('div');
62+
document.body.appendChild(host);
63+
host.style.background = 'rgba(255,0,0,0.1)';
64+
const shadowRoot = host.attachShadow({
65+
mode: 'open',
66+
delegatesFocus: false,
67+
});
68+
const container = document.createElement('div');
69+
shadowRoot.appendChild(container);
70+
71+
createRoot(container).render(<Demo />);
72+
}, []);
73+
74+
return null;
75+
};

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
"@types/classnames": "^2.2.10",
4747
"@types/jest": "^26.0.15",
4848
"@types/react": "^16.8.19",
49-
"@types/react-dom": "^16.8.4",
49+
"@types/react-dom": "^18.0.11",
5050
"cross-env": "^7.0.1",
5151
"dumi": "^2.1.0",
5252
"eslint": "^7.0.0",

src/index.tsx

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import useEvent from 'rc-util/lib/hooks/useEvent';
77
import useId from 'rc-util/lib/hooks/useId';
88
import useLayoutEffect from 'rc-util/lib/hooks/useLayoutEffect';
99
import isMobile from 'rc-util/lib/isMobile';
10+
import warning from 'rc-util/lib/warning';
1011
import * as React from 'react';
1112
import type { TriggerContextProps } from './context';
1213
import TriggerContext from './context';
@@ -246,10 +247,13 @@ export function generateTrigger(
246247

247248
const inPopupOrChild = useEvent((ele: any) => {
248249
const childDOM = targetEle;
250+
249251
return (
250252
childDOM?.contains(ele) ||
253+
(childDOM?.getRootNode() as ShadowRoot)?.host === ele ||
251254
ele === childDOM ||
252255
popupEle?.contains(ele) ||
256+
(popupEle?.getRootNode() as ShadowRoot)?.host === ele ||
253257
ele === popupEle ||
254258
Object.values(subPopupElements.current).some(
255259
(subPopupEle) => subPopupEle.contains(ele) || ele === subPopupEle,
@@ -497,13 +501,38 @@ export function generateTrigger(
497501

498502
const win = getWin(popupEle);
499503

504+
const targetRoot = targetEle?.getRootNode();
505+
500506
win.addEventListener('click', onWindowClick);
501507

508+
// shadow root
509+
const inShadow = targetRoot && targetRoot !== targetEle.ownerDocument;
510+
if (inShadow) {
511+
(targetRoot as ShadowRoot).addEventListener('click', onWindowClick);
512+
}
513+
514+
// Warning if target and popup not in same root
515+
if (process.env.NODE_ENV !== 'production') {
516+
const popupRoot = popupEle.getRootNode();
517+
518+
warning(
519+
targetRoot === popupRoot,
520+
`trigger element and popup element should in same shadow root.`,
521+
);
522+
}
523+
502524
return () => {
503525
win.removeEventListener('click', onWindowClick);
526+
527+
if (inShadow) {
528+
(targetRoot as ShadowRoot).removeEventListener(
529+
'click',
530+
onWindowClick,
531+
);
532+
}
504533
};
505534
}
506-
}, [clickToHide, popupEle, mask, maskClosable]);
535+
}, [clickToHide, targetEle, popupEle, mask, maskClosable]);
507536

508537
// ======================= Action: Hover ========================
509538
const hoverToShow = showActions.has('hover');

tests/shadow.test.tsx

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { act, fireEvent } from '@testing-library/react';
2+
import { resetWarned } from 'rc-util/lib/warning';
3+
import React from 'react';
4+
import { createRoot } from 'react-dom/client';
5+
import Trigger from '../src';
6+
import { awaitFakeTimer } from './util';
7+
8+
describe('Trigger.Shadow', () => {
9+
beforeEach(() => {
10+
resetWarned();
11+
jest.useFakeTimers();
12+
});
13+
14+
afterEach(() => {
15+
jest.useRealTimers();
16+
});
17+
18+
const Demo: React.FC = (props?: any) => (
19+
<>
20+
<Trigger
21+
action={['click']}
22+
popup={<span className="bamboo" />}
23+
builtinPlacements={{
24+
top: {},
25+
}}
26+
popupPlacement="top"
27+
{...props}
28+
>
29+
<p className="target" />
30+
</Trigger>
31+
32+
{/* Placeholder element which not related with Trigger */}
33+
<div className="little" />
34+
</>
35+
);
36+
37+
const renderShadow = (props?: any) => {
38+
const host = document.createElement('div');
39+
document.body.appendChild(host);
40+
41+
const shadowRoot = host.attachShadow({
42+
mode: 'open',
43+
delegatesFocus: false,
44+
});
45+
const container = document.createElement('div');
46+
shadowRoot.appendChild(container);
47+
48+
act(() => {
49+
createRoot(container).render(<Demo {...props} />);
50+
});
51+
52+
return shadowRoot;
53+
};
54+
55+
it('popup not in the same shadow', async () => {
56+
const errSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
57+
const shadowRoot = renderShadow();
58+
59+
await awaitFakeTimer();
60+
61+
fireEvent.click(shadowRoot.querySelector('.target'));
62+
63+
await awaitFakeTimer();
64+
65+
expect(errSpy).toHaveBeenCalledWith(
66+
`Warning: trigger element and popup element should in same shadow root.`,
67+
);
68+
errSpy.mockRestore();
69+
});
70+
71+
it('click in shadow should not close popup', async () => {
72+
const errSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
73+
const shadowRoot = renderShadow({
74+
getPopupContainer: (item: HTMLElement) => item.parentElement,
75+
autoDestroy: true,
76+
});
77+
78+
await awaitFakeTimer();
79+
80+
// Click to show
81+
fireEvent.click(shadowRoot.querySelector('.target'));
82+
await awaitFakeTimer();
83+
expect(shadowRoot.querySelector('.bamboo')).toBeTruthy();
84+
85+
// Click outside to hide
86+
fireEvent.click(document.body.firstChild);
87+
await awaitFakeTimer();
88+
expect(shadowRoot.querySelector('.bamboo')).toBeFalsy();
89+
90+
// Click to show again
91+
fireEvent.click(shadowRoot.querySelector('.target'));
92+
await awaitFakeTimer();
93+
expect(shadowRoot.querySelector('.bamboo')).toBeTruthy();
94+
95+
// Click in side shadow to hide
96+
fireEvent.click(shadowRoot.querySelector('.little'));
97+
await awaitFakeTimer();
98+
expect(shadowRoot.querySelector('.bamboo')).toBeFalsy();
99+
100+
expect(errSpy).not.toHaveBeenCalled();
101+
errSpy.mockRestore();
102+
});
103+
});

0 commit comments

Comments
 (0)