diff --git a/packages/mui-base/src/ClickAwayListener/ClickAwayListener.test.js b/packages/mui-base/src/ClickAwayListener/ClickAwayListener.test.js new file mode 100644 index 00000000000000..1cbd6e67d15e50 --- /dev/null +++ b/packages/mui-base/src/ClickAwayListener/ClickAwayListener.test.js @@ -0,0 +1,441 @@ +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; +import { expect } from 'chai'; +import { spy } from 'sinon'; +import { + act, + createRenderer, + fireEvent, + fireDiscreteEvent, + screen, +} from '@mui/internal-test-utils'; +import { Portal } from '@mui/base/Portal'; +import { ClickAwayListener } from '@mui/base/ClickAwayListener'; + +describe('', () => { + const { render: clientRender, clock } = createRenderer({ clock: 'fake' }); + /** + * @type {typeof plainRender extends (...args: infer T) => any ? T : never} args + * + * @remarks + * This is for all intents and purposes the same as our client render method. + * `plainRender` is already wrapped in act(). + * However, React has a bug that flushes effects in a portal synchronously. + * We have to defer the effect manually like `useEffect` would so we have to flush the effect manually instead of relying on `act()`. + * React bug: https://github.com/facebook/react/issues/20074 + */ + function render(...args) { + const result = clientRender(...args); + clock.tick(0); + return result; + } + + it('should render the children', () => { + const children = ; + const { container } = render( + {}}>{children}, + ); + expect(container.querySelectorAll('span').length).to.equal(1); + }); + + describe('prop: onClickAway', () => { + it('should be called when clicking away', () => { + const handleClickAway = spy(); + render( + + + , + ); + + fireEvent.click(document.body); + expect(handleClickAway.callCount).to.equal(1); + expect(handleClickAway.args[0].length).to.equal(1); + }); + + it('should not be called when clicking inside', () => { + const handleClickAway = spy(); + const { container } = render( + + + , + ); + + fireEvent.click(container.querySelector('span')); + expect(handleClickAway.callCount).to.equal(0); + }); + + it('should be called when preventDefault is `true`', () => { + const handleClickAway = spy(); + render( + + + , + ); + const preventDefault = (event) => event.preventDefault(); + document.body.addEventListener('click', preventDefault); + + fireEvent.click(document.body); + expect(handleClickAway.callCount).to.equal(1); + + document.body.removeEventListener('click', preventDefault); + }); + + it('should not be called when clicking inside a portaled element', () => { + const handleClickAway = spy(); + const { getByText } = render( + +
+ + Inside a portal + +
+
, + ); + + fireEvent.click(getByText('Inside a portal')); + expect(handleClickAway.callCount).to.equal(0); + }); + + it('should be called when clicking inside a portaled element and `disableReactTree` is `true`', () => { + const handleClickAway = spy(); + const { getByText } = render( + +
+ + Inside a portal + +
+
, + ); + + fireEvent.click(getByText('Inside a portal')); + expect(handleClickAway.callCount).to.equal(1); + }); + + it('should not be called even if the event propagation is stopped', () => { + const handleClickAway = spy(); + const { getByText } = render( + +
+
{ + event.stopPropagation(); + }} + > + Outside a portal +
+ + { + event.stopPropagation(); + }} + > + Stop inside a portal + + + + { + event.stopPropagation(); + event.nativeEvent.stopImmediatePropagation(); + }} + > + Stop all inside a portal + + +
+
, + ); + + fireEvent.click(getByText('Outside a portal')); + expect(handleClickAway.callCount).to.equal(0); + + fireEvent.click(getByText('Stop all inside a portal')); + expect(handleClickAway.callCount).to.equal(0); + + fireEvent.click(getByText('Stop inside a portal')); + // undesired behavior in React 16 + expect(handleClickAway.callCount).to.equal(React.version.startsWith('16') ? 1 : 0); + }); + + ['onClick', 'onClickCapture'].forEach((eventListenerName) => { + it(`should not be called when ${eventListenerName} mounted the listener`, () => { + function Test() { + const [open, setOpen] = React.useState(false); + + return ( + + + )} + + + ); + } + + const { setProps } = render(); + + expect(screen.getByTestId('root')).toHaveFocus(); + act(() => { + screen.getByTestId('hide-button').focus(); + }); + expect(screen.getByTestId('hide-button')).toHaveFocus(); + + setProps({ hideButton: true }); + expect(screen.getByTestId('root')).not.toHaveFocus(); + clock.tick(500); // wait for the interval check to kick in. + expect(screen.getByTestId('root')).toHaveFocus(); + }); + + describe('prop: disableAutoFocus', () => { + it('should not trap', () => { + const { getByRole } = render( +
+ + +
+ +
, + ); + + clock.tick(500); // trigger an interval call + expect(initialFocus).toHaveFocus(); + + act(() => { + getByRole('textbox').focus(); + }); + expect(getByRole('textbox')).toHaveFocus(); + }); + + it('should trap once the focus moves inside', () => { + render( +
+ + +
+
+
+
, + ); + + expect(initialFocus).toHaveFocus(); + + act(() => { + screen.getByTestId('outside-input').focus(); + }); + expect(screen.getByTestId('outside-input')).toHaveFocus(); + + // the trap activates + act(() => { + screen.getByTestId('focus-input').focus(); + }); + expect(screen.getByTestId('focus-input')).toHaveFocus(); + + // the trap prevent to escape + act(() => { + screen.getByTestId('outside-input').focus(); + }); + expect(screen.getByTestId('root')).toHaveFocus(); + }); + + it('should restore the focus', () => { + function Test(props: GenericProps) { + return ( +
+ + +
+ +
+
+
+ ); + } + + const { setProps } = render(); + + // set the expected focus restore location + act(() => { + screen.getByTestId('outside-input').focus(); + }); + expect(screen.getByTestId('outside-input')).toHaveFocus(); + + // the trap activates + act(() => { + screen.getByTestId('root').focus(); + }); + expect(screen.getByTestId('root')).toHaveFocus(); + + // restore the focus to the first element before triggering the trap + setProps({ open: false }); + expect(screen.getByTestId('outside-input')).toHaveFocus(); + }); + }); + }); +}); diff --git a/packages/mui-base/src/FocusTrap/FocusTrap.tsx b/packages/mui-base/src/FocusTrap/FocusTrap.tsx new file mode 100644 index 00000000000000..d17c562746eeed --- /dev/null +++ b/packages/mui-base/src/FocusTrap/FocusTrap.tsx @@ -0,0 +1,434 @@ +'use client'; +/* eslint-disable consistent-return, jsx-a11y/no-noninteractive-tabindex */ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { + exactProp, + elementAcceptingRef, + unstable_useForkRef as useForkRef, + unstable_ownerDocument as ownerDocument, + unstable_getReactNodeRef as getReactNodeRef, +} from '@mui/utils'; +import { FocusTrapProps } from './FocusTrap.types'; + +// Inspired by https://github.com/focus-trap/tabbable +const candidatesSelector = [ + 'input', + 'select', + 'textarea', + 'a[href]', + 'button', + '[tabindex]', + 'audio[controls]', + 'video[controls]', + '[contenteditable]:not([contenteditable="false"])', +].join(','); + +interface OrderedTabNode { + documentOrder: number; + tabIndex: number; + node: HTMLElement; +} + +function getTabIndex(node: HTMLElement): number { + const tabindexAttr = parseInt(node.getAttribute('tabindex') || '', 10); + + if (!Number.isNaN(tabindexAttr)) { + return tabindexAttr; + } + + // Browsers do not return `tabIndex` correctly for contentEditable nodes; + // https://bugs.chromium.org/p/chromium/issues/detail?id=661108&q=contenteditable%20tabindex&can=2 + // so if they don't have a tabindex attribute specifically set, assume it's 0. + // in Chrome,
,