From f6e03180c82e5387c0605d2e43116ab2f776083f Mon Sep 17 00:00:00 2001 From: Olivier Tassinari Date: Thu, 12 Sep 2024 00:59:19 +0200 Subject: [PATCH] [core] Include history from the @mui/base components & hooks v4 --- .../ClickAwayListener.test.js | 441 +++++++++++ .../ClickAwayListener/ClickAwayListener.tsx | 258 ++++++ .../mui-base/src/FocusTrap/FocusTrap.test.tsx | 410 ++++++++++ packages/mui-base/src/FocusTrap/FocusTrap.tsx | 434 ++++++++++ .../mui-base/src/FocusTrap/FocusTrap.types.ts | 52 ++ packages/mui-base/src/NoSsr/NoSsr.test.tsx | 56 ++ packages/mui-base/src/NoSsr/NoSsr.types.ts | 19 + packages/mui-base/src/Popper/Popper.tsx | 545 +++++++++++++ packages/mui-base/src/Popper/Popper.types.ts | 154 ++++ packages/mui-base/src/Portal/Portal.test.tsx | 220 ++++++ packages/mui-base/src/Portal/Portal.tsx | 108 +++ packages/mui-base/src/Portal/Portal.types.ts | 24 + .../TextareaAutosize.test.tsx | 465 +++++++++++ .../src/TextareaAutosize/TextareaAutosize.tsx | 265 +++++++ .../TextareaAutosize.types.ts | 15 + .../unstable_useModal/ModalManager.test.ts | 435 ++++++++++ .../src/unstable_useModal/ModalManager.ts | 314 ++++++++ .../src/unstable_useModal/useModal.ts | 240 ++++++ .../src/unstable_useModal/useModal.types.ts | 123 +++ .../useAutocomplete/useAutocomplete.spec.ts | 184 +++++ .../useAutocomplete/useAutocomplete.test.js | 398 ++++++++++ packages/mui-base/src/useBadge/useBadge.ts | 46 ++ .../mui-base/src/useBadge/useBadge.types.ts | 40 + packages/mui-base/src/useSlider/useSlider.ts | 747 ++++++++++++++++++ .../mui-base/src/useSlider/useSlider.types.ts | 259 ++++++ .../src/useSnackbar/useSnackbar.test.tsx | 53 ++ .../mui-base/src/useSnackbar/useSnackbar.ts | 164 ++++ .../src/useSnackbar/useSnackbar.types.ts | 66 ++ 28 files changed, 6535 insertions(+) create mode 100644 packages/mui-base/src/ClickAwayListener/ClickAwayListener.test.js create mode 100644 packages/mui-base/src/ClickAwayListener/ClickAwayListener.tsx create mode 100644 packages/mui-base/src/FocusTrap/FocusTrap.test.tsx create mode 100644 packages/mui-base/src/FocusTrap/FocusTrap.tsx create mode 100644 packages/mui-base/src/FocusTrap/FocusTrap.types.ts create mode 100644 packages/mui-base/src/NoSsr/NoSsr.test.tsx create mode 100644 packages/mui-base/src/NoSsr/NoSsr.types.ts create mode 100644 packages/mui-base/src/Popper/Popper.tsx create mode 100644 packages/mui-base/src/Popper/Popper.types.ts create mode 100644 packages/mui-base/src/Portal/Portal.test.tsx create mode 100644 packages/mui-base/src/Portal/Portal.tsx create mode 100644 packages/mui-base/src/Portal/Portal.types.ts create mode 100644 packages/mui-base/src/TextareaAutosize/TextareaAutosize.test.tsx create mode 100644 packages/mui-base/src/TextareaAutosize/TextareaAutosize.tsx create mode 100644 packages/mui-base/src/TextareaAutosize/TextareaAutosize.types.ts create mode 100644 packages/mui-base/src/unstable_useModal/ModalManager.test.ts create mode 100644 packages/mui-base/src/unstable_useModal/ModalManager.ts create mode 100644 packages/mui-base/src/unstable_useModal/useModal.ts create mode 100644 packages/mui-base/src/unstable_useModal/useModal.types.ts create mode 100644 packages/mui-base/src/useAutocomplete/useAutocomplete.spec.ts create mode 100644 packages/mui-base/src/useAutocomplete/useAutocomplete.test.js create mode 100644 packages/mui-base/src/useBadge/useBadge.ts create mode 100644 packages/mui-base/src/useBadge/useBadge.types.ts create mode 100644 packages/mui-base/src/useSlider/useSlider.ts create mode 100644 packages/mui-base/src/useSlider/useSlider.types.ts create mode 100644 packages/mui-base/src/useSnackbar/useSnackbar.test.tsx create mode 100644 packages/mui-base/src/useSnackbar/useSnackbar.ts create mode 100644 packages/mui-base/src/useSnackbar/useSnackbar.types.ts 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,
,