Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';
import * as React from 'react';
import PropTypes from 'prop-types';
import contains from '@mui/utils/contains';
import ownerDocument from '@mui/utils/ownerDocument';
import useForkRef from '@mui/utils/useForkRef';
import useEventCallback from '@mui/utils/useEventCallback';
Expand Down Expand Up @@ -134,14 +135,8 @@ function ClickAwayListener(props: ClickAwayListenerProps): React.JSX.Element {
insideDOM = event.composedPath().includes(nodeRef.current);
} else {
insideDOM =
!doc.documentElement.contains(
// @ts-expect-error returns `false` as intended when not dispatched from a Node
event.target,
) ||
nodeRef.current.contains(
// @ts-expect-error returns `false` as intended when not dispatched from a Node
event.target,
);
!contains(doc.documentElement, event.target as Element) ||
contains(nodeRef.current, event.target as Element);
}

if (!insideDOM && (disableReactTree || !insideReactTree)) {
Expand Down
11 changes: 7 additions & 4 deletions packages/mui-material/src/Slider/useSlider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import {
} from './useSlider.types';
import { EventHandlers } from '../utils/types';
import areArraysEqual from '../utils/areArraysEqual';
import contains from '../utils/contains';
import getActiveElement from '../utils/getActiveElement';

const INTENTIONAL_DRAG_COUNT_THRESHOLD = 2;

Expand Down Expand Up @@ -132,10 +134,10 @@ function focusThumb({
activeIndex: number;
setActive?: ((num: number) => void) | undefined;
}) {
const doc = ownerDocument(sliderRef.current);
const activeElement = getActiveElement(ownerDocument(sliderRef.current));
if (
!sliderRef.current?.contains(doc.activeElement) ||
Number(doc?.activeElement?.getAttribute('data-index')) !== activeIndex
!contains(sliderRef.current, activeElement) ||
Number(activeElement?.getAttribute('data-index')) !== activeIndex
) {
sliderRef.current?.querySelector(`[type="range"][data-index="${activeIndex}"]`).focus();
}
Expand Down Expand Up @@ -433,7 +435,8 @@ export function useSlider(parameters: UseSliderParameters): UseSliderReturnValue
};

useEnhancedEffect(() => {
if (disabled && sliderRef.current!.contains(document.activeElement)) {
const activeElement = getActiveElement(ownerDocument(sliderRef.current));
if (disabled && contains(sliderRef.current, activeElement)) {
// This is necessary because Firefox and Safari will keep focus
// on a disabled element:
// https://codesandbox.io/p/sandbox/mui-pr-22247-forked-h151h?file=/src/App.js
Expand Down
11 changes: 6 additions & 5 deletions packages/mui-material/src/SwipeableDrawer/SwipeableDrawer.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ import PropTypes from 'prop-types';
import elementTypeAcceptingRef from '@mui/utils/elementTypeAcceptingRef';
import NoSsr from '../NoSsr';
import Drawer, { getAnchor, isHorizontal } from '../Drawer/Drawer';
import useForkRef from '../utils/useForkRef';
import contains from '../utils/contains';
import ownerDocument from '../utils/ownerDocument';
import ownerWindow from '../utils/ownerWindow';
import useEventCallback from '../utils/useEventCallback';
import useForkRef from '../utils/useForkRef';
import useEnhancedEffect from '../utils/useEnhancedEffect';
import { useTheme } from '../zero-styled';
import { useDefaultProps } from '../DefaultPropsProvider';
Expand Down Expand Up @@ -366,7 +367,7 @@ const SwipeableDrawer = React.forwardRef(function SwipeableDrawer(inProps, ref)
ownerWindow(nativeEvent.currentTarget),
);

if (open && paperRef.current.contains(nativeEvent.target) && claimedSwipeInstance === null) {
if (open && contains(paperRef.current, nativeEvent.target) && claimedSwipeInstance === null) {
const domTreeShapes = getDomTreeShapes(nativeEvent.target, paperRef.current);
const hasNativeHandler = computeHasNativeHandler({
domTreeShapes,
Expand Down Expand Up @@ -494,8 +495,8 @@ const SwipeableDrawer = React.forwardRef(function SwipeableDrawer(inProps, ref)
// At least one element clogs the drawer interaction zone.
if (
open &&
(hideBackdrop || !backdropRef.current.contains(nativeEvent.target)) &&
!paperRef.current.contains(nativeEvent.target)
(hideBackdrop || !contains(backdropRef.current, nativeEvent.target)) &&
!contains(paperRef.current, nativeEvent.target)
) {
return;
}
Expand Down Expand Up @@ -524,7 +525,7 @@ const SwipeableDrawer = React.forwardRef(function SwipeableDrawer(inProps, ref)
disableSwipeToOpen ||
!(
nativeEvent.target === swipeAreaRef.current ||
(paperRef.current?.contains(nativeEvent.target) &&
(contains(paperRef.current, nativeEvent.target) &&
(typeof allowSwipeInChildren === 'function'
? allowSwipeInChildren(nativeEvent, swipeAreaRef.current, paperRef.current)
: allowSwipeInChildren))
Expand Down
5 changes: 3 additions & 2 deletions packages/mui-material/src/Unstable_TrapFocus/FocusTrap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import ownerDocument from '@mui/utils/ownerDocument';
import getReactElementRef from '@mui/utils/getReactElementRef';
import exactProp from '@mui/utils/exactProp';
import elementAcceptingRef from '@mui/utils/elementAcceptingRef';
import contains from '../utils/contains';
import getActiveElement from '../utils/getActiveElement';
import { FocusTrapProps } from './FocusTrap.types';

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

if (!rootRef.current.contains(activeElement)) {
if (!contains(rootRef.current, activeElement)) {
if (!rootRef.current.hasAttribute('tabIndex')) {
if (process.env.NODE_ENV !== 'production') {
console.error(
Expand Down Expand Up @@ -250,7 +251,7 @@ function FocusTrap(props: FocusTrapProps): React.JSX.Element {
}

// The focus is already inside
if (rootElement.contains(activeEl)) {
if (contains(rootElement, activeEl)) {
return;
}

Expand Down
11 changes: 6 additions & 5 deletions packages/mui-material/src/useAutocomplete/useAutocomplete.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

import * as React from 'react';
import contains from '@mui/utils/contains';
import setRef from '@mui/utils/setRef';
import useEventCallback from '@mui/utils/useEventCallback';
import useControlled from '@mui/utils/useControlled';
Expand Down Expand Up @@ -64,7 +65,7 @@ const defaultFilterOptions = createFilterOptions();
const pageSize = 5;

const defaultIsActiveElementInListbox = (listboxRef) =>
listboxRef.current !== null && listboxRef.current.parentElement?.contains(document.activeElement);
listboxRef.current !== null && contains(listboxRef.current.parentElement, document.activeElement);

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

Expand Down Expand Up @@ -1152,11 +1153,11 @@ function useAutocomplete(props) {
// Prevent input blur when interacting with the combobox
const handleMouseDown = (event) => {
// Prevent focusing the input if click is anywhere outside the Autocomplete
if (!event.currentTarget.contains(event.target)) {
if (!contains(event.currentTarget, event.target)) {
return;
}
// Don't interfere with interactions outside the input area (e.g. helper text)
if (anchorEl && !anchorEl.contains(event.target)) {
if (anchorEl && !contains(anchorEl, event.target)) {
return;
}
if (event.target.getAttribute('id') !== id) {
Expand All @@ -1167,11 +1168,11 @@ function useAutocomplete(props) {
// Focus the input when interacting with the combobox
const handleClick = (event) => {
// Prevent focusing the input if click is anywhere outside the Autocomplete
if (!event.currentTarget.contains(event.target)) {
if (!contains(event.currentTarget, event.target)) {
return;
}
// Don't interfere with interactions outside the input area (e.g. helper text)
if (anchorEl && !anchorEl.contains(event.target)) {
if (anchorEl && !contains(anchorEl, event.target)) {
return;
}
inputRef.current.focus();
Expand Down
3 changes: 3 additions & 0 deletions packages/mui-material/src/utils/contains.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import contains from '@mui/utils/contains';

export default contains;
3 changes: 3 additions & 0 deletions packages/mui-material/src/utils/getEventTarget.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import getEventTarget from '@mui/utils/getEventTarget';

export default getEventTarget;
110 changes: 110 additions & 0 deletions packages/mui-utils/src/contains/contains.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { expect } from 'chai';
import contains from './contains';

describe('contains', () => {
it('should return false when parent is null', () => {
const child = document.createElement('div');
expect(contains(null, child)).to.equal(false);
});

it('should return false when child is null', () => {
const parent = document.createElement('div');
expect(contains(parent, null)).to.equal(false);
});

it('should return true for direct parent-child relationship', () => {
const parent = document.createElement('div');
const child = document.createElement('span');
parent.appendChild(child);
document.body.appendChild(parent);

expect(contains(parent, child)).to.equal(true);

document.body.removeChild(parent);
});

it('should return true for deeply nested descendants', () => {
const parent = document.createElement('div');
const middle = document.createElement('div');
const child = document.createElement('span');
parent.appendChild(middle);
middle.appendChild(child);
document.body.appendChild(parent);

expect(contains(parent, child)).to.equal(true);

document.body.removeChild(parent);
});

it('should return false when elements are not related', () => {
const a = document.createElement('div');
const b = document.createElement('div');
document.body.appendChild(a);
document.body.appendChild(b);

expect(contains(a, b)).to.equal(false);

document.body.removeChild(a);
document.body.removeChild(b);
});

it('should return true when child is inside an open shadow root of a descendant', () => {
const parent = document.createElement('div');
const host = document.createElement('div');
parent.appendChild(host);
document.body.appendChild(parent);

const shadowRoot = host.attachShadow({ mode: 'open' });
const child = document.createElement('button');
shadowRoot.appendChild(child);

// Native contains returns false across shadow boundaries
expect(parent.contains(child)).to.equal(false);
// Our contains traverses shadow roots
expect(contains(parent, child)).to.equal(true);

document.body.removeChild(parent);
});

it('should return true when child is inside a closed shadow root of a descendant', () => {
const parent = document.createElement('div');
const host = document.createElement('div');
parent.appendChild(host);
document.body.appendChild(parent);

const shadowRoot = host.attachShadow({ mode: 'closed' });
const child = document.createElement('button');
shadowRoot.appendChild(child);

// Native contains returns false across shadow boundaries
expect(parent.contains(child)).to.equal(false);
// Our contains traverses shadow roots
expect(contains(parent, child)).to.equal(true);

document.body.removeChild(parent);
});

it('should return true when child is inside nested shadow roots', () => {
const parent = document.createElement('div');
const outerHost = document.createElement('div');
parent.appendChild(outerHost);
document.body.appendChild(parent);

const outerShadow = outerHost.attachShadow({ mode: 'open' });
const innerHost = document.createElement('div');
outerShadow.appendChild(innerHost);

const innerShadow = innerHost.attachShadow({ mode: 'open' });
const child = document.createElement('button');
innerShadow.appendChild(child);

expect(contains(parent, child)).to.equal(true);

document.body.removeChild(parent);
});

it('should return true when parent and child are the same element', () => {
const element = document.createElement('div');
expect(contains(element, element)).to.equal(true);
});
});
40 changes: 40 additions & 0 deletions packages/mui-utils/src/contains/contains.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* Copied from @base-ui/utils
*
* Shadow DOM-aware containment check.
*
* Native `parent.contains(child)` returns `false` when the child is inside a
* shadow root that is a descendant of the parent. This function handles that
* case by traversing up through shadow root hosts.
*
* @param parent - The potential ancestor element.
* @param child - The potential descendant element.
* @returns Whether `parent` contains `child`, even across shadow root boundaries.
*/
export default function contains(
parent: Element | null | undefined,
child: Element | null | undefined,
): boolean {
if (!parent || !child) {
return false;
}

// First, attempt with the faster native method.
if (parent.contains(child)) {
return true;
}

// Then fall back to traversing out of shadow roots when needed.
const rootNode = child.getRootNode?.();
if (rootNode && rootNode instanceof ShadowRoot) {
let next: Node | null = child;
while (next) {
if (parent === next) {
return true;
}
next = next.parentNode ?? (next as ShadowRoot).host ?? null;
}
}

return false;
}
1 change: 1 addition & 0 deletions packages/mui-utils/src/contains/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './contains';
46 changes: 46 additions & 0 deletions packages/mui-utils/src/getEventTarget/getEventTarget.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { expect } from 'chai';
import getEventTarget from './getEventTarget';

describe('getEventTarget', () => {
it('should return the event target for a regular DOM event', () => {
const button = document.createElement('button');
document.body.appendChild(button);

let capturedTarget: EventTarget | null = null;
button.addEventListener('click', (event) => {
capturedTarget = getEventTarget(event);
});
button.click();

expect(capturedTarget).to.equal(button);

document.body.removeChild(button);
});

it('should return the actual target inside an open shadow root', () => {
const host = document.createElement('div');
document.body.appendChild(host);
const shadowRoot = host.attachShadow({ mode: 'open' });

const button = document.createElement('button');
shadowRoot.appendChild(button);

let capturedTarget: EventTarget | null = null;
// Listen on the host (outside the shadow root)
host.addEventListener('click', (event) => {
capturedTarget = getEventTarget(event);
});
button.click();

// composedPath()[0] gives us the actual button, not the host
expect(capturedTarget).to.equal(button);

document.body.removeChild(host);
});

it('should return null for an event with no composedPath and no target', () => {
const event = new Event('custom');
// event.target is null before dispatch
expect(getEventTarget(event)).to.equal(null);
});
});
16 changes: 16 additions & 0 deletions packages/mui-utils/src/getEventTarget/getEventTarget.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* Copied from @base-ui/utils
*
* Gets the actual target of an event, using `composedPath()` to traverse
* shadow DOM boundaries.
*
* In shadow DOM, `event.target` may return the shadow host rather than the
* actual element that triggered the event. `composedPath()[0]` returns the
* true originating element.
*
* @param event - The event to get the target from.
* @returns The actual event target, or `null` if not available.
*/
export default function getEventTarget(event: Event): EventTarget | null {
return event.composedPath?.()[0] ?? event.target;
}
1 change: 1 addition & 0 deletions packages/mui-utils/src/getEventTarget/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './getEventTarget';
Loading
Loading