Skip to content

Commit f9b590e

Browse files
authored
[utils] Add shadow dom utils (mui#48256)
1 parent f417078 commit f9b590e

16 files changed

Lines changed: 246 additions & 23 deletions

File tree

packages/mui-material/src/ClickAwayListener/ClickAwayListener.tsx

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use client';
22
import * as React from 'react';
33
import PropTypes from 'prop-types';
4+
import contains from '@mui/utils/contains';
45
import ownerDocument from '@mui/utils/ownerDocument';
56
import useForkRef from '@mui/utils/useForkRef';
67
import useEventCallback from '@mui/utils/useEventCallback';
@@ -134,14 +135,8 @@ function ClickAwayListener(props: ClickAwayListenerProps): React.JSX.Element {
134135
insideDOM = event.composedPath().includes(nodeRef.current);
135136
} else {
136137
insideDOM =
137-
!doc.documentElement.contains(
138-
// @ts-expect-error returns `false` as intended when not dispatched from a Node
139-
event.target,
140-
) ||
141-
nodeRef.current.contains(
142-
// @ts-expect-error returns `false` as intended when not dispatched from a Node
143-
event.target,
144-
);
138+
!contains(doc.documentElement, event.target as Element) ||
139+
contains(nodeRef.current, event.target as Element);
145140
}
146141

147142
if (!insideDOM && (disableReactTree || !insideReactTree)) {

packages/mui-material/src/MenuList/MenuList.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import * as React from 'react';
33
import PropTypes from 'prop-types';
44
import { isItemFocusable } from '@mui/utils/useRovingTabIndex';
5+
import contains from '../utils/contains';
56
import ownerDocument from '../utils/ownerDocument';
67
import getActiveElement from '../utils/getActiveElement';
78
import getScrollbarSize from '../utils/getScrollbarSize';
@@ -207,7 +208,7 @@ const MenuList = React.forwardRef(function MenuList(props, ref) {
207208

208209
const currentFocus = getActiveElement(ownerDocument(listRef.current));
209210

210-
if (currentFocus && listRef.current.contains(currentFocus)) {
211+
if (currentFocus && contains(listRef.current, currentFocus)) {
211212
return currentFocus;
212213
}
213214

packages/mui-material/src/Slider/useSlider.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
} from './useSlider.types';
2020
import { EventHandlers } from '../utils/types';
2121
import areArraysEqual from '../utils/areArraysEqual';
22+
import contains from '../utils/contains';
2223
import getActiveElement from '../utils/getActiveElement';
2324

2425
const INTENTIONAL_DRAG_COUNT_THRESHOLD = 2;
@@ -137,7 +138,7 @@ function focusThumb(
137138
const doc = ownerDocument(sliderRef.current);
138139
const activeElement = getActiveElement(doc);
139140
if (
140-
!sliderRef.current?.contains(activeElement) ||
141+
!contains(sliderRef.current, activeElement) ||
141142
Number(activeElement?.getAttribute('data-index')) !== activeIndex
142143
) {
143144
const input = sliderRef.current?.querySelector(
@@ -469,7 +470,7 @@ export function useSlider(parameters: UseSliderParameters): UseSliderReturnValue
469470

470471
useEnhancedEffect(() => {
471472
const activeElement = getActiveElement(ownerDocument(sliderRef.current));
472-
if (disabled && sliderRef.current?.contains(activeElement)) {
473+
if (disabled && contains(sliderRef.current, activeElement)) {
473474
// This is necessary because Firefox and Safari will keep focus
474475
// on a disabled element:
475476
// https://codesandbox.io/p/sandbox/mui-pr-22247-forked-h151h?file=/src/App.js

packages/mui-material/src/SwipeableDrawer/SwipeableDrawer.js

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import * as ReactDOM from 'react-dom';
44
import PropTypes from 'prop-types';
55
import NoSsr from '../NoSsr';
66
import Drawer, { getAnchor, isHorizontal } from '../Drawer/Drawer';
7+
import contains from '../utils/contains';
78
import ownerDocument from '../utils/ownerDocument';
89
import ownerWindow from '../utils/ownerWindow';
910
import useEventCallback from '../utils/useEventCallback';
@@ -360,7 +361,7 @@ const SwipeableDrawer = React.forwardRef(function SwipeableDrawer(inProps, ref)
360361
ownerWindow(nativeEvent.currentTarget),
361362
);
362363

363-
if (open && paperRef.current.contains(nativeEvent.target) && claimedSwipeInstance === null) {
364+
if (open && contains(paperRef.current, nativeEvent.target) && claimedSwipeInstance === null) {
364365
const domTreeShapes = getDomTreeShapes(nativeEvent.target, paperRef.current);
365366
const hasNativeHandler = computeHasNativeHandler({
366367
domTreeShapes,
@@ -488,8 +489,8 @@ const SwipeableDrawer = React.forwardRef(function SwipeableDrawer(inProps, ref)
488489
// At least one element clogs the drawer interaction zone.
489490
if (
490491
open &&
491-
(hideBackdrop || !backdropRef.current.contains(nativeEvent.target)) &&
492-
!paperRef.current.contains(nativeEvent.target)
492+
(hideBackdrop || !contains(backdropRef.current, nativeEvent.target)) &&
493+
!contains(paperRef.current, nativeEvent.target)
493494
) {
494495
return;
495496
}
@@ -518,7 +519,7 @@ const SwipeableDrawer = React.forwardRef(function SwipeableDrawer(inProps, ref)
518519
disableSwipeToOpen ||
519520
!(
520521
nativeEvent.target === swipeAreaRef.current ||
521-
(paperRef.current?.contains(nativeEvent.target) &&
522+
(contains(paperRef.current, nativeEvent.target) &&
522523
(typeof allowSwipeInChildren === 'function'
523524
? allowSwipeInChildren(nativeEvent, swipeAreaRef.current, paperRef.current)
524525
: allowSwipeInChildren))

packages/mui-material/src/Tabs/Tabs.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import tabsClasses, { getTabsUtilityClass } from './tabsClasses';
1919
import ownerWindow from '../utils/ownerWindow';
2020
import isLayoutSupported from '../utils/isLayoutSupported';
2121
import useSlot from '../utils/useSlot';
22+
import contains from '../utils/contains';
2223
import getActiveElement from '../utils/getActiveElement';
2324
import ownerDocument from '../utils/ownerDocument';
2425
import useForkRef from '../utils/useForkRef';
@@ -818,7 +819,7 @@ const Tabs = React.forwardRef(function Tabs(inProps, ref) {
818819
getSlotProps: (handlers) => ({
819820
...handlers,
820821
onBlur: (event) => {
821-
if (!event.currentTarget.contains(event.relatedTarget)) {
822+
if (!contains(event.currentTarget, event.relatedTarget)) {
822823
setIsFocusWithinList(false);
823824
}
824825

packages/mui-material/src/Unstable_TrapFocus/FocusTrap.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import ownerDocument from '@mui/utils/ownerDocument';
77
import getReactElementRef from '@mui/utils/getReactElementRef';
88
import exactProp from '@mui/utils/exactProp';
99
import elementAcceptingRef from '@mui/utils/elementAcceptingRef';
10+
import contains from '../utils/contains';
1011
import getActiveElement from '../utils/getActiveElement';
1112
import { FocusTrapProps } from './FocusTrap.types';
1213

@@ -165,7 +166,7 @@ function FocusTrap(props: FocusTrapProps): React.JSX.Element {
165166
const doc = ownerDocument(rootRef.current);
166167
const activeElement = getActiveElement(doc);
167168

168-
if (!rootRef.current.contains(activeElement)) {
169+
if (!contains(rootRef.current, activeElement)) {
169170
if (!rootRef.current.hasAttribute('tabIndex')) {
170171
if (process.env.NODE_ENV !== 'production') {
171172
console.error(
@@ -250,7 +251,7 @@ function FocusTrap(props: FocusTrapProps): React.JSX.Element {
250251
}
251252

252253
// The focus is already inside
253-
if (rootElement.contains(activeEl)) {
254+
if (contains(rootElement, activeEl)) {
254255
return;
255256
}
256257

packages/mui-material/src/useAutocomplete/useAutocomplete.js

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use client';
22

33
import * as React from 'react';
4+
import contains from '@mui/utils/contains';
45
import setRef from '@mui/utils/setRef';
56
import useEventCallback from '@mui/utils/useEventCallback';
67
import useControlled from '@mui/utils/useControlled';
@@ -64,7 +65,7 @@ const defaultFilterOptions = createFilterOptions();
6465
const pageSize = 5;
6566

6667
const defaultIsActiveElementInListbox = (listboxRef) =>
67-
listboxRef.current !== null && listboxRef.current.parentElement?.contains(document.activeElement);
68+
listboxRef.current !== null && contains(listboxRef.current.parentElement, document.activeElement);
6869

6970
const defaultIsOptionEqualToValue = (option, value) => option === value;
7071

@@ -1150,11 +1151,11 @@ function useAutocomplete(props) {
11501151
// Prevent input blur when interacting with the combobox
11511152
const handleMouseDown = (event) => {
11521153
// Prevent focusing the input if click is anywhere outside the Autocomplete
1153-
if (!event.currentTarget.contains(event.target)) {
1154+
if (!contains(event.currentTarget, event.target)) {
11541155
return;
11551156
}
11561157
// Don't interfere with interactions outside the input area (e.g. helper text)
1157-
if (anchorEl && !anchorEl.contains(event.target)) {
1158+
if (anchorEl && !contains(anchorEl, event.target)) {
11581159
return;
11591160
}
11601161
if (event.target.getAttribute('id') !== id) {
@@ -1165,11 +1166,11 @@ function useAutocomplete(props) {
11651166
// Focus the input when interacting with the combobox
11661167
const handleClick = (event) => {
11671168
// Prevent focusing the input if click is anywhere outside the Autocomplete
1168-
if (!event.currentTarget.contains(event.target)) {
1169+
if (!contains(event.currentTarget, event.target)) {
11691170
return;
11701171
}
11711172
// Don't interfere with interactions outside the input area (e.g. helper text)
1172-
if (anchorEl && !anchorEl.contains(event.target)) {
1173+
if (anchorEl && !contains(anchorEl, event.target)) {
11731174
return;
11741175
}
11751176
inputRef.current.focus();
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import contains from '@mui/utils/contains';
2+
3+
export default contains;
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import getEventTarget from '@mui/utils/getEventTarget';
2+
3+
export default getEventTarget;
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { expect } from 'chai';
2+
import contains from './contains';
3+
4+
describe('contains', () => {
5+
it('should return false when parent is null', () => {
6+
const child = document.createElement('div');
7+
expect(contains(null, child)).to.equal(false);
8+
});
9+
10+
it('should return false when child is null', () => {
11+
const parent = document.createElement('div');
12+
expect(contains(parent, null)).to.equal(false);
13+
});
14+
15+
it('should return true for direct parent-child relationship', () => {
16+
const parent = document.createElement('div');
17+
const child = document.createElement('span');
18+
parent.appendChild(child);
19+
document.body.appendChild(parent);
20+
21+
expect(contains(parent, child)).to.equal(true);
22+
23+
document.body.removeChild(parent);
24+
});
25+
26+
it('should return true for deeply nested descendants', () => {
27+
const parent = document.createElement('div');
28+
const middle = document.createElement('div');
29+
const child = document.createElement('span');
30+
parent.appendChild(middle);
31+
middle.appendChild(child);
32+
document.body.appendChild(parent);
33+
34+
expect(contains(parent, child)).to.equal(true);
35+
36+
document.body.removeChild(parent);
37+
});
38+
39+
it('should return false when elements are not related', () => {
40+
const a = document.createElement('div');
41+
const b = document.createElement('div');
42+
document.body.appendChild(a);
43+
document.body.appendChild(b);
44+
45+
expect(contains(a, b)).to.equal(false);
46+
47+
document.body.removeChild(a);
48+
document.body.removeChild(b);
49+
});
50+
51+
it('should return true when child is inside an open shadow root of a descendant', () => {
52+
const parent = document.createElement('div');
53+
const host = document.createElement('div');
54+
parent.appendChild(host);
55+
document.body.appendChild(parent);
56+
57+
const shadowRoot = host.attachShadow({ mode: 'open' });
58+
const child = document.createElement('button');
59+
shadowRoot.appendChild(child);
60+
61+
// Native contains returns false across shadow boundaries
62+
expect(parent.contains(child)).to.equal(false);
63+
// Our contains traverses shadow roots
64+
expect(contains(parent, child)).to.equal(true);
65+
66+
document.body.removeChild(parent);
67+
});
68+
69+
it('should return true when child is inside a closed shadow root of a descendant', () => {
70+
const parent = document.createElement('div');
71+
const host = document.createElement('div');
72+
parent.appendChild(host);
73+
document.body.appendChild(parent);
74+
75+
const shadowRoot = host.attachShadow({ mode: 'closed' });
76+
const child = document.createElement('button');
77+
shadowRoot.appendChild(child);
78+
79+
// Native contains returns false across shadow boundaries
80+
expect(parent.contains(child)).to.equal(false);
81+
// Our contains traverses shadow roots
82+
expect(contains(parent, child)).to.equal(true);
83+
84+
document.body.removeChild(parent);
85+
});
86+
87+
it('should return true when child is inside nested shadow roots', () => {
88+
const parent = document.createElement('div');
89+
const outerHost = document.createElement('div');
90+
parent.appendChild(outerHost);
91+
document.body.appendChild(parent);
92+
93+
const outerShadow = outerHost.attachShadow({ mode: 'open' });
94+
const innerHost = document.createElement('div');
95+
outerShadow.appendChild(innerHost);
96+
97+
const innerShadow = innerHost.attachShadow({ mode: 'open' });
98+
const child = document.createElement('button');
99+
innerShadow.appendChild(child);
100+
101+
expect(contains(parent, child)).to.equal(true);
102+
103+
document.body.removeChild(parent);
104+
});
105+
106+
it('should return true when parent and child are the same element', () => {
107+
const element = document.createElement('div');
108+
expect(contains(element, element)).to.equal(true);
109+
});
110+
});

0 commit comments

Comments
 (0)