From d8c5595ee4f78af62cf94e6d034a4e500f3f50da Mon Sep 17 00:00:00 2001 From: mnajdova Date: Thu, 11 Jul 2024 10:16:44 +0200 Subject: [PATCH 01/24] Move @mui/base/utils --- packages/mui-material/src/Badge/Badge.d.ts | 2 +- packages/mui-material/src/Badge/Badge.js | 2 +- .../src/Breadcrumbs/Breadcrumbs.d.ts | 2 +- .../src/Breadcrumbs/Breadcrumbs.js | 2 +- .../mui-material/src/InputBase/InputBase.js | 2 +- .../mui-material/src/ListItem/ListItem.js | 2 +- packages/mui-material/src/Menu/Menu.js | 2 +- packages/mui-material/src/Modal/Modal.d.ts | 2 +- packages/mui-material/src/Popover/Popover.js | 2 +- packages/mui-material/src/Slider/Slider.d.ts | 2 +- packages/mui-material/src/Slider/Slider.js | 3 +- .../mui-material/src/Snackbar/Snackbar.js | 2 +- .../src/TabScrollButton/TabScrollButton.d.ts | 2 +- .../src/TabScrollButton/TabScrollButton.js | 2 +- .../src/TablePagination/TablePagination.js | 2 +- packages/mui-material/src/Tabs/Tabs.d.ts | 2 +- packages/mui-material/src/Tabs/Tabs.js | 2 +- packages/mui-material/src/Tooltip/Tooltip.js | 2 +- .../src/utils/appendOwnerState.spec.tsx | 28 ++ .../src/utils/appendOwnerState.test.ts | 67 ++++ .../src/utils/appendOwnerState.ts | 51 +++ .../src/utils/extractEventHandlers.test.ts | 43 +++ .../src/utils/extractEventHandlers.ts | 30 ++ .../mui-material/src/utils/isHostComponent.ts | 8 + .../mui-material/src/utils/mergeSlotProps.ts | 182 +++++++++++ .../src/utils/omitEventHandlers.ts | 24 ++ .../src/utils/resolveComponentProps.ts | 21 ++ .../src/utils/shouldSpreadAdditionalProps.js | 2 +- packages/mui-material/src/utils/types.ts | 27 +- packages/mui-material/src/utils/useSlot.ts | 4 +- .../src/utils/useSlotProps.test.tsx | 301 ++++++++++++++++++ .../mui-material/src/utils/useSlotProps.ts | 113 +++++++ 32 files changed, 917 insertions(+), 21 deletions(-) create mode 100644 packages/mui-material/src/utils/appendOwnerState.spec.tsx create mode 100644 packages/mui-material/src/utils/appendOwnerState.test.ts create mode 100644 packages/mui-material/src/utils/appendOwnerState.ts create mode 100644 packages/mui-material/src/utils/extractEventHandlers.test.ts create mode 100644 packages/mui-material/src/utils/extractEventHandlers.ts create mode 100644 packages/mui-material/src/utils/isHostComponent.ts create mode 100644 packages/mui-material/src/utils/mergeSlotProps.ts create mode 100644 packages/mui-material/src/utils/omitEventHandlers.ts create mode 100644 packages/mui-material/src/utils/resolveComponentProps.ts create mode 100644 packages/mui-material/src/utils/useSlotProps.test.tsx create mode 100644 packages/mui-material/src/utils/useSlotProps.ts diff --git a/packages/mui-material/src/Badge/Badge.d.ts b/packages/mui-material/src/Badge/Badge.d.ts index a9811fa9a86552..fc4f37a095ad14 100644 --- a/packages/mui-material/src/Badge/Badge.d.ts +++ b/packages/mui-material/src/Badge/Badge.d.ts @@ -1,7 +1,7 @@ import * as React from 'react'; import { SxProps } from '@mui/system'; import { OverridableStringUnion, Simplify } from '@mui/types'; -import { SlotComponentProps } from '@mui/base/utils'; +import { SlotComponentProps } from '../utils/types'; import { Theme } from '../styles'; import { OverridableComponent, OverrideProps } from '../OverridableComponent'; import { BadgeClasses } from './badgeClasses'; diff --git a/packages/mui-material/src/Badge/Badge.js b/packages/mui-material/src/Badge/Badge.js index d3543f3c12715d..33743ddac8fcfd 100644 --- a/packages/mui-material/src/Badge/Badge.js +++ b/packages/mui-material/src/Badge/Badge.js @@ -5,7 +5,7 @@ import clsx from 'clsx'; import usePreviousProps from '@mui/utils/usePreviousProps'; import composeClasses from '@mui/utils/composeClasses'; import { useBadge } from '@mui/base/useBadge'; -import { useSlotProps } from '@mui/base/utils'; +import { useSlotProps } from '../utils/useSlotProps'; import { styled } from '../zero-styled'; import { useDefaultProps } from '../DefaultPropsProvider'; import capitalize from '../utils/capitalize'; diff --git a/packages/mui-material/src/Breadcrumbs/Breadcrumbs.d.ts b/packages/mui-material/src/Breadcrumbs/Breadcrumbs.d.ts index 972f2460cc2dbb..4c58c98f7a7225 100644 --- a/packages/mui-material/src/Breadcrumbs/Breadcrumbs.d.ts +++ b/packages/mui-material/src/Breadcrumbs/Breadcrumbs.d.ts @@ -1,6 +1,6 @@ import * as React from 'react'; import { SxProps } from '@mui/system'; -import { SlotComponentProps } from '@mui/base'; +import { SlotComponentProps } from '../utils/types'; import { Theme } from '../styles'; import { OverridableComponent, OverrideProps } from '../OverridableComponent'; import { BreadcrumbsClasses } from './breadcrumbsClasses'; diff --git a/packages/mui-material/src/Breadcrumbs/Breadcrumbs.js b/packages/mui-material/src/Breadcrumbs/Breadcrumbs.js index 45a83fbbe6c62c..584febc2aad1d2 100644 --- a/packages/mui-material/src/Breadcrumbs/Breadcrumbs.js +++ b/packages/mui-material/src/Breadcrumbs/Breadcrumbs.js @@ -4,8 +4,8 @@ import { isFragment } from 'react-is'; import PropTypes from 'prop-types'; import clsx from 'clsx'; import integerPropType from '@mui/utils/integerPropType'; -import { useSlotProps } from '@mui/base/utils'; import composeClasses from '@mui/utils/composeClasses'; +import { useSlotProps } from '../utils/useSlotProps'; import { styled } from '../zero-styled'; import { useDefaultProps } from '../DefaultPropsProvider'; import Typography from '../Typography'; diff --git a/packages/mui-material/src/InputBase/InputBase.js b/packages/mui-material/src/InputBase/InputBase.js index f92d068cdc41ca..799a913434e4dc 100644 --- a/packages/mui-material/src/InputBase/InputBase.js +++ b/packages/mui-material/src/InputBase/InputBase.js @@ -6,8 +6,8 @@ import elementTypeAcceptingRef from '@mui/utils/elementTypeAcceptingRef'; import refType from '@mui/utils/refType'; import MuiError from '@mui/internal-babel-macros/MuiError.macro'; import { TextareaAutosize } from '@mui/base'; -import { isHostComponent } from '@mui/base/utils'; import composeClasses from '@mui/utils/composeClasses'; +import { isHostComponent } from '../utils/isHostComponent'; import formControlState from '../FormControl/formControlState'; import FormControlContext from '../FormControl/FormControlContext'; import useFormControl from '../FormControl/useFormControl'; diff --git a/packages/mui-material/src/ListItem/ListItem.js b/packages/mui-material/src/ListItem/ListItem.js index 187bcaf84db17d..a2e29790843525 100644 --- a/packages/mui-material/src/ListItem/ListItem.js +++ b/packages/mui-material/src/ListItem/ListItem.js @@ -2,11 +2,11 @@ import * as React from 'react'; import PropTypes from 'prop-types'; import clsx from 'clsx'; -import { isHostComponent } from '@mui/base/utils'; import composeClasses from '@mui/utils/composeClasses'; import elementTypeAcceptingRef from '@mui/utils/elementTypeAcceptingRef'; import chainPropTypes from '@mui/utils/chainPropTypes'; import { alpha } from '@mui/system/colorManipulator'; +import { isHostComponent } from '../utils/isHostComponent'; import { styled } from '../zero-styled'; import { useDefaultProps } from '../DefaultPropsProvider'; import ButtonBase from '../ButtonBase'; diff --git a/packages/mui-material/src/Menu/Menu.js b/packages/mui-material/src/Menu/Menu.js index 821f544ab16e83..041de4b94e6e5e 100644 --- a/packages/mui-material/src/Menu/Menu.js +++ b/packages/mui-material/src/Menu/Menu.js @@ -4,9 +4,9 @@ import { isFragment } from 'react-is'; import PropTypes from 'prop-types'; import clsx from 'clsx'; import composeClasses from '@mui/utils/composeClasses'; -import { useSlotProps } from '@mui/base/utils'; import HTMLElementType from '@mui/utils/HTMLElementType'; import { useRtl } from '@mui/system/RtlProvider'; +import { useSlotProps } from '../utils/useSlotProps'; import MenuList from '../MenuList'; import Popover, { PopoverPaper } from '../Popover'; import rootShouldForwardProp from '../styles/rootShouldForwardProp'; diff --git a/packages/mui-material/src/Modal/Modal.d.ts b/packages/mui-material/src/Modal/Modal.d.ts index 9a8c34c3935718..58f064a6f2a8ce 100644 --- a/packages/mui-material/src/Modal/Modal.d.ts +++ b/packages/mui-material/src/Modal/Modal.d.ts @@ -1,7 +1,7 @@ import * as React from 'react'; import { SxProps } from '@mui/system'; import { OverrideProps } from '@mui/types'; -import { SlotComponentProps } from '@mui/base'; +import { SlotComponentProps } from '../utils/types'; import { PortalProps } from '../Portal'; import { Theme } from '../styles'; import Backdrop, { BackdropProps } from '../Backdrop'; diff --git a/packages/mui-material/src/Popover/Popover.js b/packages/mui-material/src/Popover/Popover.js index 427818eedef29f..2d8ca24ef10581 100644 --- a/packages/mui-material/src/Popover/Popover.js +++ b/packages/mui-material/src/Popover/Popover.js @@ -2,13 +2,13 @@ import * as React from 'react'; import PropTypes from 'prop-types'; import clsx from 'clsx'; -import { isHostComponent } from '@mui/base/utils'; import composeClasses from '@mui/utils/composeClasses'; import HTMLElementType from '@mui/utils/HTMLElementType'; import refType from '@mui/utils/refType'; import elementTypeAcceptingRef from '@mui/utils/elementTypeAcceptingRef'; import integerPropType from '@mui/utils/integerPropType'; import chainPropTypes from '@mui/utils/chainPropTypes'; +import { isHostComponent } from '../utils/isHostComponent'; import { styled } from '../zero-styled'; import { useDefaultProps } from '../DefaultPropsProvider'; import debounce from '../utils/debounce'; diff --git a/packages/mui-material/src/Slider/Slider.d.ts b/packages/mui-material/src/Slider/Slider.d.ts index 40c7b6ecb1139c..9f4a130528322c 100644 --- a/packages/mui-material/src/Slider/Slider.d.ts +++ b/packages/mui-material/src/Slider/Slider.d.ts @@ -1,8 +1,8 @@ import * as React from 'react'; -import { SlotComponentProps } from '@mui/base'; import { Mark } from '@mui/base/useSlider'; import { SxProps } from '@mui/system'; import { OverridableStringUnion } from '@mui/types'; +import { SlotComponentProps } from '../utils/types'; import { Theme } from '../styles'; import { OverrideProps, OverridableComponent } from '../OverridableComponent'; import SliderValueLabelComponent from './SliderValueLabel'; diff --git a/packages/mui-material/src/Slider/Slider.js b/packages/mui-material/src/Slider/Slider.js index ada5fda52457f3..b70430506121d6 100644 --- a/packages/mui-material/src/Slider/Slider.js +++ b/packages/mui-material/src/Slider/Slider.js @@ -3,11 +3,12 @@ import * as React from 'react'; import PropTypes from 'prop-types'; import clsx from 'clsx'; import chainPropTypes from '@mui/utils/chainPropTypes'; -import { isHostComponent, useSlotProps } from '@mui/base/utils'; import composeClasses from '@mui/utils/composeClasses'; import { useSlider, valueToPercent } from '@mui/base/useSlider'; import { alpha, lighten, darken } from '@mui/system/colorManipulator'; import { useRtl } from '@mui/system/RtlProvider'; +import { useSlotProps } from '../utils/useSlotProps'; +import { isHostComponent } from '../utils/isHostComponent'; import { styled } from '../zero-styled'; import { useDefaultProps } from '../DefaultPropsProvider'; import slotShouldForwardProp from '../styles/slotShouldForwardProp'; diff --git a/packages/mui-material/src/Snackbar/Snackbar.js b/packages/mui-material/src/Snackbar/Snackbar.js index 70abf93913296f..ffa25df207a30e 100644 --- a/packages/mui-material/src/Snackbar/Snackbar.js +++ b/packages/mui-material/src/Snackbar/Snackbar.js @@ -1,11 +1,11 @@ 'use client'; import * as React from 'react'; import PropTypes from 'prop-types'; -import { useSlotProps } from '@mui/base/utils'; import composeClasses from '@mui/utils/composeClasses'; import { ClickAwayListener } from '@mui/base/ClickAwayListener'; import { useSnackbar } from '@mui/base/useSnackbar'; import { styled, useTheme } from '../zero-styled'; +import { useSlotProps } from '../utils/useSlotProps'; import { useDefaultProps } from '../DefaultPropsProvider'; import capitalize from '../utils/capitalize'; import Grow from '../Grow'; diff --git a/packages/mui-material/src/TabScrollButton/TabScrollButton.d.ts b/packages/mui-material/src/TabScrollButton/TabScrollButton.d.ts index 9dd31b73bbee48..aefc6db919ce78 100644 --- a/packages/mui-material/src/TabScrollButton/TabScrollButton.d.ts +++ b/packages/mui-material/src/TabScrollButton/TabScrollButton.d.ts @@ -1,6 +1,6 @@ import * as React from 'react'; import { SxProps } from '@mui/system'; -import { SlotComponentProps } from '@mui/base'; +import { SlotComponentProps } from '../utils/types'; import { ButtonBaseProps } from '../ButtonBase'; import { SvgIcon, Theme } from '..'; import { TabScrollButtonClasses } from './tabScrollButtonClasses'; diff --git a/packages/mui-material/src/TabScrollButton/TabScrollButton.js b/packages/mui-material/src/TabScrollButton/TabScrollButton.js index a331bde367a628..55763560d07aaf 100644 --- a/packages/mui-material/src/TabScrollButton/TabScrollButton.js +++ b/packages/mui-material/src/TabScrollButton/TabScrollButton.js @@ -3,9 +3,9 @@ import * as React from 'react'; import PropTypes from 'prop-types'; import clsx from 'clsx'; -import { useSlotProps } from '@mui/base/utils'; import composeClasses from '@mui/utils/composeClasses'; import { useRtl } from '@mui/system/RtlProvider'; +import { useSlotProps } from '../utils/useSlotProps'; import KeyboardArrowLeft from '../internal/svg-icons/KeyboardArrowLeft'; import KeyboardArrowRight from '../internal/svg-icons/KeyboardArrowRight'; import ButtonBase from '../ButtonBase'; diff --git a/packages/mui-material/src/TablePagination/TablePagination.js b/packages/mui-material/src/TablePagination/TablePagination.js index e55660a64c54f2..0fad86497cc1d9 100644 --- a/packages/mui-material/src/TablePagination/TablePagination.js +++ b/packages/mui-material/src/TablePagination/TablePagination.js @@ -4,8 +4,8 @@ import PropTypes from 'prop-types'; import clsx from 'clsx'; import integerPropType from '@mui/utils/integerPropType'; import chainPropTypes from '@mui/utils/chainPropTypes'; -import { isHostComponent } from '@mui/base/utils'; import composeClasses from '@mui/utils/composeClasses'; +import { isHostComponent } from '../utils/isHostComponent'; import { styled } from '../zero-styled'; import { useDefaultProps } from '../DefaultPropsProvider'; import InputBase from '../InputBase'; diff --git a/packages/mui-material/src/Tabs/Tabs.d.ts b/packages/mui-material/src/Tabs/Tabs.d.ts index 7a9dc023ca311d..e27af76d493d6e 100644 --- a/packages/mui-material/src/Tabs/Tabs.d.ts +++ b/packages/mui-material/src/Tabs/Tabs.d.ts @@ -1,7 +1,7 @@ import * as React from 'react'; import { SxProps } from '@mui/system'; -import { SlotComponentProps } from '@mui/base'; import { OverridableStringUnion } from '@mui/types'; +import { SlotComponentProps } from '../utils/types'; import { Theme } from '../styles'; import { TabScrollButtonProps } from '../TabScrollButton'; import { OverridableComponent, OverrideProps } from '../OverridableComponent'; diff --git a/packages/mui-material/src/Tabs/Tabs.js b/packages/mui-material/src/Tabs/Tabs.js index 6d02a527234fb8..5f3379cc5389a0 100644 --- a/packages/mui-material/src/Tabs/Tabs.js +++ b/packages/mui-material/src/Tabs/Tabs.js @@ -4,7 +4,7 @@ import { isFragment } from 'react-is'; import PropTypes from 'prop-types'; import clsx from 'clsx'; import refType from '@mui/utils/refType'; -import { useSlotProps } from '@mui/base/utils'; +import { useSlotProps } from '../utils/useSlotProps'; import composeClasses from '@mui/utils/composeClasses'; import { useRtl } from '@mui/system/RtlProvider'; import { styled, useTheme } from '../zero-styled'; diff --git a/packages/mui-material/src/Tooltip/Tooltip.js b/packages/mui-material/src/Tooltip/Tooltip.js index a179afe021eaa4..15539ab62eaeb9 100644 --- a/packages/mui-material/src/Tooltip/Tooltip.js +++ b/packages/mui-material/src/Tooltip/Tooltip.js @@ -4,11 +4,11 @@ import PropTypes from 'prop-types'; import clsx from 'clsx'; import useTimeout, { Timeout } from '@mui/utils/useTimeout'; import elementAcceptingRef from '@mui/utils/elementAcceptingRef'; -import { appendOwnerState } from '@mui/base/utils'; import composeClasses from '@mui/utils/composeClasses'; import { alpha } from '@mui/system/colorManipulator'; import { useRtl } from '@mui/system/RtlProvider'; import isFocusVisible from '@mui/utils/isFocusVisible'; +import { appendOwnerState } from '../utils/appendOwnerState'; import { styled, useTheme } from '../zero-styled'; import { useDefaultProps } from '../DefaultPropsProvider'; import capitalize from '../utils/capitalize'; diff --git a/packages/mui-material/src/utils/appendOwnerState.spec.tsx b/packages/mui-material/src/utils/appendOwnerState.spec.tsx new file mode 100644 index 00000000000000..73ebb2a4a5700d --- /dev/null +++ b/packages/mui-material/src/utils/appendOwnerState.spec.tsx @@ -0,0 +1,28 @@ +import * as React from 'react'; +import { appendOwnerState } from './appendOwnerState'; + +const divProps = appendOwnerState('div', { otherProp: true }, { ownerStateProps: true }); + +// ownerState is not available on a host component +// @ts-expect-error +const test1 = divProps.ownerState.ownerStateProps; +// @ts-expect-error +const test2 = divProps.ownerState?.ownerStateProps; + +const componentProps = appendOwnerState( + () =>
, + { otherProp: true }, + { ownerStateProps: true }, +); + +// ownerState is present on a custom component +const test3: boolean = componentProps.ownerState.ownerStateProps; + +function test(element: React.ElementType) { + const props = appendOwnerState(element, { otherProp: true }, { ownerStateProps: true }); + + // ownerState may be present on a provided element type (it depends on its exact type) + // @ts-expect-error + const test4 = props.ownerState.ownerStateProps; + const test5: boolean | undefined = props.ownerState?.ownerStateProps; +} diff --git a/packages/mui-material/src/utils/appendOwnerState.test.ts b/packages/mui-material/src/utils/appendOwnerState.test.ts new file mode 100644 index 00000000000000..4955df7674d6af --- /dev/null +++ b/packages/mui-material/src/utils/appendOwnerState.test.ts @@ -0,0 +1,67 @@ +import { expect } from 'chai'; +import { appendOwnerState } from './appendOwnerState'; + +const ownerState = { + className: 'bar', + checked: true, +}; + +function CustomComponent() { + return null; +} + +describe('appendOwnerState', () => { + describe('when the provided elementType is undefined', () => { + it('returns the provided existingProps without modification ', () => { + const existingProps = { className: 'foo' }; + const actual = appendOwnerState(undefined, existingProps, ownerState); + + expect(actual).to.equal(existingProps); + }); + }); + + describe('when a DOM element is provided as elementType', () => { + it('returns the provided existingProps without modification ', () => { + const existingProps = { className: 'foo' }; + const actual = appendOwnerState('div', existingProps, ownerState); + + expect(actual).to.equal(existingProps); + }); + }); + + describe('when a React component is provided as elementType', () => { + it('returns the provided existingProps with added ownerState', () => { + const existingProps = { className: 'foo' }; + const actual = appendOwnerState(CustomComponent, existingProps, ownerState); + + expect(actual).to.deep.equal({ + className: 'foo', + ownerState: { + className: 'bar', + checked: true, + }, + }); + }); + + it('merges the provided ownerState with existing ones', () => { + const existingProps = { + ownerState: { + className: 'foo', + id: 'foo', + }, + className: 'foo', + }; + + const actual = appendOwnerState(CustomComponent, existingProps, ownerState); + + expect(actual).to.deep.equal({ + className: 'foo', + ownerState: { + className: 'bar', + id: 'foo', + checked: true, + }, + }); + }); + }); +}); diff --git a/packages/mui-material/src/utils/appendOwnerState.ts b/packages/mui-material/src/utils/appendOwnerState.ts new file mode 100644 index 00000000000000..965867cd8e71f0 --- /dev/null +++ b/packages/mui-material/src/utils/appendOwnerState.ts @@ -0,0 +1,51 @@ +import * as React from 'react'; +import { Simplify } from '@mui/types'; +import { isHostComponent } from './isHostComponent'; + +/** + * Type of the ownerState based on the type of an element it applies to. + * This resolves to the provided OwnerState for React components and `undefined` for host components. + * Falls back to `OwnerState | undefined` when the exact type can't be determined in development time. + */ +type OwnerStateWhenApplicable = + ElementType extends React.ComponentType + ? OwnerState + : ElementType extends keyof React.JSX.IntrinsicElements + ? undefined + : OwnerState | undefined; + +export type AppendOwnerStateReturnType< + ElementType extends React.ElementType, + OtherProps, + OwnerState, +> = Simplify< + OtherProps & { + ownerState: OwnerStateWhenApplicable; + } +>; + +/** + * Appends the ownerState object to the props, merging with the existing one if necessary. + * + * @param elementType Type of the element that owns the `existingProps`. If the element is a DOM node or undefined, `ownerState` is not applied. + * @param otherProps Props of the element. + * @param ownerState + */ +export function appendOwnerState< + ElementType extends React.ElementType, + OtherProps extends Record, + OwnerState, +>( + elementType: ElementType | undefined, + otherProps: OtherProps, + ownerState: OwnerState, +): AppendOwnerStateReturnType { + if (elementType === undefined || isHostComponent(elementType)) { + return otherProps as AppendOwnerStateReturnType; + } + + return { + ...otherProps, + ownerState: { ...otherProps.ownerState, ...ownerState }, + } as AppendOwnerStateReturnType; +} diff --git a/packages/mui-material/src/utils/extractEventHandlers.test.ts b/packages/mui-material/src/utils/extractEventHandlers.test.ts new file mode 100644 index 00000000000000..130f5dd51472c5 --- /dev/null +++ b/packages/mui-material/src/utils/extractEventHandlers.test.ts @@ -0,0 +1,43 @@ +import { expect } from 'chai'; +import { extractEventHandlers } from './extractEventHandlers'; + +describe('extractEventHandlers', () => { + it('extracts the fields starting with `on[A-Z]` and being a function', () => { + const input = { + onClick: () => {}, + onChange: () => {}, + once: () => {}, + on: () => {}, + onInvalid: 0, + on1: () => {}, + xonClick: () => {}, + }; + + const result = extractEventHandlers(input); + expect(result).to.deep.equal({ + onClick: input.onClick, + onChange: input.onChange, + }); + }); + + it('returns an empty object if an empty object is provided', () => { + const result = extractEventHandlers({}); + expect(result).to.deep.equal({}); + }); + + it('returns an empty object if undefined is passed in', () => { + const result = extractEventHandlers(undefined); + expect(result).to.deep.equal({}); + }); + + it('excludes the provided handlers from the result', () => { + const input = { + onClick: () => {}, + onChange: () => {}, + onFocus: () => {}, + }; + + const result = extractEventHandlers(input, ['onClick', 'onFocus']); + expect(result).to.deep.equal({ onChange: input.onChange }); + }); +}); diff --git a/packages/mui-material/src/utils/extractEventHandlers.ts b/packages/mui-material/src/utils/extractEventHandlers.ts new file mode 100644 index 00000000000000..e3998c358e0240 --- /dev/null +++ b/packages/mui-material/src/utils/extractEventHandlers.ts @@ -0,0 +1,30 @@ +import { EventHandlers } from './types'; + +/** + * Extracts event handlers from a given object. + * A prop is considered an event handler if it is a function and its name starts with `on`. + * + * @param object An object to extract event handlers from. + * @param excludeKeys An array of keys to exclude from the returned object. + */ +export function extractEventHandlers( + object: Record | undefined, + excludeKeys: string[] = [], +): EventHandlers { + if (object === undefined) { + return {}; + } + + const result: EventHandlers = {}; + + Object.keys(object) + .filter( + (prop) => + prop.match(/^on[A-Z]/) && typeof object[prop] === 'function' && !excludeKeys.includes(prop), + ) + .forEach((prop) => { + result[prop] = object[prop]; + }); + + return result; +} diff --git a/packages/mui-material/src/utils/isHostComponent.ts b/packages/mui-material/src/utils/isHostComponent.ts new file mode 100644 index 00000000000000..7c9c7e1613942f --- /dev/null +++ b/packages/mui-material/src/utils/isHostComponent.ts @@ -0,0 +1,8 @@ +import * as React from 'react'; + +/** + * Determines if a given element is a DOM element name (i.e. not a React component). + */ +export function isHostComponent(element: React.ElementType) { + return typeof element === 'string'; +} diff --git a/packages/mui-material/src/utils/mergeSlotProps.ts b/packages/mui-material/src/utils/mergeSlotProps.ts new file mode 100644 index 00000000000000..5bd696fbb4f1e3 --- /dev/null +++ b/packages/mui-material/src/utils/mergeSlotProps.ts @@ -0,0 +1,182 @@ +import * as React from 'react'; +import clsx, { ClassValue } from 'clsx'; +import { Simplify } from '@mui/types'; +import { EventHandlers } from './types'; +import { extractEventHandlers } from './extractEventHandlers'; +import { omitEventHandlers } from './omitEventHandlers'; + +export type WithCommonProps = OtherProps & { + className?: string; + style?: React.CSSProperties; + ref?: React.Ref; +}; + +export interface MergeSlotPropsParameters< + SlotProps, + ExternalForwardedProps, + ExternalSlotProps, + AdditionalProps, +> { + /** + * A function that returns the internal props of the component. + * It accepts the event handlers passed into the component by the user + * and is responsible for calling them where appropriate. + */ + getSlotProps?: (other: EventHandlers) => WithCommonProps; + /** + * Props provided to the `slotProps.*` of the Base UI component. + */ + externalSlotProps?: WithCommonProps; + /** + * Extra props placed on the Base UI component that should be forwarded to the slot. + * This should usually be used only for the root slot. + */ + externalForwardedProps?: WithCommonProps; + /** + * Additional props to be placed on the slot. + */ + additionalProps?: WithCommonProps; + /** + * Extra class name(s) to be placed on the slot. + */ + className?: ClassValue | ClassValue[]; +} + +export type MergeSlotPropsResult< + SlotProps, + ExternalForwardedProps, + ExternalSlotProps, + AdditionalProps, +> = { + props: Simplify< + SlotProps & + ExternalForwardedProps & + ExternalSlotProps & + AdditionalProps & { className?: string; style?: React.CSSProperties } + >; + internalRef: React.Ref | undefined; +}; + +/** + * Merges the slot component internal props (usually coming from a hook) + * with the externally provided ones. + * + * The merge order is (the latter overrides the former): + * 1. The internal props (specified as a getter function to work with get*Props hook result) + * 2. Additional props (specified internally on a Base UI component) + * 3. External props specified on the owner component. These should only be used on a root slot. + * 4. External props specified in the `slotProps.*` prop. + * 5. The `className` prop - combined from all the above. + * @param parameters + * @returns + */ +export function mergeSlotProps< + SlotProps, + ExternalForwardedProps extends Record, + ExternalSlotProps extends Record, + AdditionalProps, +>( + parameters: MergeSlotPropsParameters< + SlotProps, + ExternalForwardedProps, + ExternalSlotProps, + AdditionalProps + >, +): MergeSlotPropsResult { + const { getSlotProps, additionalProps, externalSlotProps, externalForwardedProps, className } = + parameters; + + if (!getSlotProps) { + // The simpler case - getSlotProps is not defined, so no internal event handlers are defined, + // so we can simply merge all the props without having to worry about extracting event handlers. + const joinedClasses = clsx( + additionalProps?.className, + className, + externalForwardedProps?.className, + externalSlotProps?.className, + ); + + const mergedStyle = { + ...additionalProps?.style, + ...externalForwardedProps?.style, + ...externalSlotProps?.style, + }; + + const props = { + ...additionalProps, + ...externalForwardedProps, + ...externalSlotProps, + } as MergeSlotPropsResult< + SlotProps, + ExternalForwardedProps, + ExternalSlotProps, + AdditionalProps + >['props']; + + if (joinedClasses.length > 0) { + props.className = joinedClasses; + } + + if (Object.keys(mergedStyle).length > 0) { + props.style = mergedStyle; + } + + return { + props, + internalRef: undefined, + }; + } + + // In this case, getSlotProps is responsible for calling the external event handlers. + // We don't need to include them in the merged props because of this. + + const eventHandlers = extractEventHandlers({ ...externalForwardedProps, ...externalSlotProps }); + const componentsPropsWithoutEventHandlers = omitEventHandlers(externalSlotProps); + const otherPropsWithoutEventHandlers = omitEventHandlers(externalForwardedProps); + + const internalSlotProps = getSlotProps(eventHandlers); + + // The order of classes is important here. + // Emotion (that we use in libraries consuming Base UI) depends on this order + // to properly override style. It requires the most important classes to be last + // (see https://github.com/mui/material-ui/pull/33205) for the related discussion. + const joinedClasses = clsx( + internalSlotProps?.className, + additionalProps?.className, + className, + externalForwardedProps?.className, + externalSlotProps?.className, + ); + + const mergedStyle = { + ...internalSlotProps?.style, + ...additionalProps?.style, + ...externalForwardedProps?.style, + ...externalSlotProps?.style, + }; + + const props = { + ...internalSlotProps, + ...additionalProps, + ...otherPropsWithoutEventHandlers, + ...componentsPropsWithoutEventHandlers, + } as MergeSlotPropsResult< + SlotProps, + ExternalForwardedProps, + ExternalSlotProps, + AdditionalProps + >['props']; + + if (joinedClasses.length > 0) { + props.className = joinedClasses; + } + + if (Object.keys(mergedStyle).length > 0) { + props.style = mergedStyle; + } + + return { + props, + internalRef: internalSlotProps.ref, + }; +} diff --git a/packages/mui-material/src/utils/omitEventHandlers.ts b/packages/mui-material/src/utils/omitEventHandlers.ts new file mode 100644 index 00000000000000..def3feec5821a4 --- /dev/null +++ b/packages/mui-material/src/utils/omitEventHandlers.ts @@ -0,0 +1,24 @@ +/** + * Removes event handlers from the given object. + * A field is considered an event handler if it is a function with a name beginning with `on`. + * + * @param object Object to remove event handlers from. + * @returns Object with event handlers removed. + */ +export function omitEventHandlers>( + object: Props | undefined, +) { + if (object === undefined) { + return {}; + } + + const result = {} as Partial; + + Object.keys(object) + .filter((prop) => !(prop.match(/^on[A-Z]/) && typeof object[prop] === 'function')) + .forEach((prop) => { + (result[prop] as any) = object[prop]; + }); + + return result; +} diff --git a/packages/mui-material/src/utils/resolveComponentProps.ts b/packages/mui-material/src/utils/resolveComponentProps.ts new file mode 100644 index 00000000000000..bb5892f1ce0973 --- /dev/null +++ b/packages/mui-material/src/utils/resolveComponentProps.ts @@ -0,0 +1,21 @@ +/** + * If `componentProps` is a function, calls it with the provided `ownerState`. + * Otherwise, just returns `componentProps`. + */ +export function resolveComponentProps( + componentProps: + | TProps + | ((ownerState: TOwnerState, slotState?: TSlotState) => TProps) + | undefined, + ownerState: TOwnerState, + slotState?: TSlotState, +): TProps | undefined { + if (typeof componentProps === 'function') { + return (componentProps as (ownerState: TOwnerState, slotState?: TSlotState) => TProps)( + ownerState, + slotState, + ); + } + + return componentProps; +} diff --git a/packages/mui-material/src/utils/shouldSpreadAdditionalProps.js b/packages/mui-material/src/utils/shouldSpreadAdditionalProps.js index eb22bb12531ae4..90408398f84c37 100644 --- a/packages/mui-material/src/utils/shouldSpreadAdditionalProps.js +++ b/packages/mui-material/src/utils/shouldSpreadAdditionalProps.js @@ -1,4 +1,4 @@ -import { isHostComponent } from '@mui/base/utils'; +import { isHostComponent } from './isHostComponent'; const shouldSpreadAdditionalProps = (Slot) => { return !Slot || !isHostComponent(Slot); diff --git a/packages/mui-material/src/utils/types.ts b/packages/mui-material/src/utils/types.ts index 9a360accc8333c..7ff9fa383215f9 100644 --- a/packages/mui-material/src/utils/types.ts +++ b/packages/mui-material/src/utils/types.ts @@ -1,5 +1,22 @@ import { SxProps } from '@mui/system'; -import { SlotComponentProps } from '@mui/base'; + +export type SlotComponentProps = + | (Partial> & TOverrides) + | (( + ownerState: TOwnerState, + ) => Partial> & TOverrides); + +export type SlotComponentPropsWithSlotState< + TSlotComponent extends React.ElementType, + TOverrides, + TOwnerState, + TSlotState, +> = + | (Partial> & TOverrides) + | (( + ownerState: TOwnerState, + slotState: TSlotState, + ) => Partial> & TOverrides); export type SlotCommonProps = { component?: React.ElementType; @@ -31,3 +48,11 @@ export type CreateSlotsAndSlotProps> = [P in keyof K]?: K[P]; }; }; + +export type EventHandlers = Record>; + +export type WithOptionalOwnerState = Omit< + Props, + 'ownerState' +> & + Partial>; diff --git a/packages/mui-material/src/utils/useSlot.ts b/packages/mui-material/src/utils/useSlot.ts index 53731186cd595d..7de7c35b1b59e0 100644 --- a/packages/mui-material/src/utils/useSlot.ts +++ b/packages/mui-material/src/utils/useSlot.ts @@ -2,7 +2,9 @@ import * as React from 'react'; import { ClassValue } from 'clsx'; import useForkRef from '@mui/utils/useForkRef'; -import { appendOwnerState, resolveComponentProps, mergeSlotProps } from '@mui/base/utils'; +import { appendOwnerState } from './appendOwnerState'; +import { resolveComponentProps } from './resolveComponentProps'; +import { mergeSlotProps } from './mergeSlotProps'; export type WithCommonProps = T & { className?: string; diff --git a/packages/mui-material/src/utils/useSlotProps.test.tsx b/packages/mui-material/src/utils/useSlotProps.test.tsx new file mode 100644 index 00000000000000..49b6c87454ce0e --- /dev/null +++ b/packages/mui-material/src/utils/useSlotProps.test.tsx @@ -0,0 +1,301 @@ +import * as React from 'react'; +import { expect } from 'chai'; +import { spy } from 'sinon'; +import { createRenderer } from '@mui/internal-test-utils'; +import { EventHandlers } from './types'; +import { useSlotProps, UseSlotPropsParameters, UseSlotPropsResult } from './useSlotProps'; + +const { render } = createRenderer(); + +function callUseSlotProps< + ElementType extends React.ElementType, + SlotProps, + ExternalForwardedProps, + ExternalSlotProps extends Record, + AdditionalProps, + OwnerState, +>( + parameters: UseSlotPropsParameters< + ElementType, + SlotProps, + ExternalForwardedProps, + ExternalSlotProps, + AdditionalProps, + OwnerState + >, +) { + const TestComponent = React.forwardRef( + ( + _: unknown, + ref: React.Ref>, + ) => { + const slotProps = useSlotProps(parameters); + React.useImperativeHandle(ref, () => slotProps as any); + return null; + }, + ); + + const ref = + React.createRef>(); + render(); + + return ref.current!; +} + +describe('useSlotProps', () => { + it('returns the provided slot props if no overrides are present', () => { + const clickHandler = () => {}; + const getSlotProps = (otherHandlers: EventHandlers) => { + expect(otherHandlers).to.deep.equal({}); + + return { + id: 'test', + onClick: clickHandler, + }; + }; + + const result = callUseSlotProps({ + elementType: 'div', + getSlotProps, + externalSlotProps: undefined, + ownerState: undefined, + }); + + expect(result).to.deep.equal({ + id: 'test', + onClick: clickHandler, + ref: null, + }); + }); + + it('calls getSlotProps with the external event handlers', () => { + const externalClickHandler = () => {}; + const internalClickHandler = () => {}; + + const getSlotProps = (otherHandlers: EventHandlers) => { + expect(otherHandlers).to.deep.equal({ + onClick: externalClickHandler, + }); + + return { + id: 'internalId', + onClick: internalClickHandler, + }; + }; + + const result = callUseSlotProps({ + elementType: 'div', + getSlotProps, + externalSlotProps: { + className: 'externalClassName', + id: 'externalId', + onClick: externalClickHandler, + }, + ownerState: undefined, + }); + + expect(result).to.deep.equal({ + className: 'externalClassName', + id: 'externalId', + onClick: internalClickHandler, + ref: null, + }); + }); + + it('adds ownerState to props if the elementType is a component', () => { + const getSlotProps = () => ({ + id: 'test', + }); + + function TestComponent(props: any) { + return
; + } + + const result = callUseSlotProps({ + elementType: TestComponent, + getSlotProps, + externalSlotProps: undefined, + ownerState: { + foo: 'bar', + }, + }); + + expect(result).to.deep.equal({ + id: 'test', + ref: null, + ownerState: { + foo: 'bar', + }, + }); + }); + + it('synchronizes refs provided by internal and external props', () => { + const internalRef = React.createRef(); + const externalRef = React.createRef(); + + const getSlotProps = () => ({ + ref: internalRef, + }); + + const result = callUseSlotProps({ + elementType: 'div', + getSlotProps, + externalSlotProps: { + ref: externalRef, + }, + ownerState: undefined, + }); + + result.ref('test'); + + expect(internalRef.current).to.equal('test'); + expect(externalRef.current).to.equal('test'); + }); + + // The "everything but the kitchen sink" test + it('constructs props from complex parameters', () => { + const internalRef = React.createRef(); + const externalRef = React.createRef(); + const additionalRef = React.createRef(); + + const internalClickHandler = spy(); + const externalClickHandler = spy(); + const externalForwardedClickHandler = spy(); + + const createInternalClickHandler = + (otherHandlers: EventHandlers) => (event: React.MouseEvent) => { + expect(otherHandlers).to.deep.equal({ + onClick: externalClickHandler, + }); + + otherHandlers.onClick(event); + internalClickHandler(event); + }; + + // usually provided by the hook: + const getSlotProps = (otherHandlers: EventHandlers) => ({ + id: 'internalId', + onClick: createInternalClickHandler(otherHandlers), + ref: internalRef, + className: 'internal', + }); + + const ownerState = { + test: true, + }; + + // provided by the user by appending additional props on the Base UI component: + const forwardedProps = { + 'data-test': 'externalForwarded', + className: 'externalForwarded', + onClick: externalForwardedClickHandler, + }; + + // provided by the user via slotProps.*: + const componentProps = (os: typeof ownerState) => ({ + 'data-fromownerstate': os.test, + 'data-test': 'externalComponentsProps', + className: 'externalComponentsProps', + onClick: externalClickHandler, + ref: externalRef, + id: 'external', + ownerState: { + foo: 'bar', + }, + }); + + // set in the Base UI component: + const additionalProps = { + className: 'additional', + ref: additionalRef, + }; + + function TestComponent(props: any) { + return
; + } + + const result = callUseSlotProps({ + elementType: TestComponent, + getSlotProps, + externalForwardedProps: forwardedProps, + externalSlotProps: componentProps, + additionalProps, + ownerState, + className: ['another-class', 'yet-another-class'], + }); + + // `id` from componentProps overrides the one from getSlotProps + expect(result).to.haveOwnProperty('id', 'external'); + + // `slotProps` is called with the ownerState + expect(result).to.haveOwnProperty('data-fromownerstate', true); + + // class names are concatenated + expect(result).to.haveOwnProperty( + 'className', + 'internal additional another-class yet-another-class externalForwarded externalComponentsProps', + ); + + // `data-test` from componentProps overrides the one from forwardedProps + expect(result).to.haveOwnProperty('data-test', 'externalComponentsProps'); + + // all refs should be synced + result.ref('test'); + expect(internalRef.current).to.equal('test'); + expect(externalRef.current).to.equal('test'); + expect(additionalRef.current).to.equal('test'); + + // event handler provided in slotProps is called + result.onClick({} as React.MouseEvent); + expect(externalClickHandler.calledOnce).to.equal(true); + + // event handler provided in forwardedProps is not called (was overridden by slotProps) + expect(externalForwardedClickHandler.notCalled).to.equal(true); + + // internal event handler is called + expect(internalClickHandler.calledOnce).to.equal(true); + + // internal ownerState is merged with the one provided by slotProps + expect(result.ownerState).to.deep.equal({ + test: true, + foo: 'bar', + }); + }); + + it('should call externalSlotProps with ownerState if skipResolvingSlotProps is not provided', () => { + const externalSlotProps = spy(); + const ownerState = { foo: 'bar' }; + + const getSlotProps = () => ({ + skipResolvingSlotProps: true, + }); + + callUseSlotProps({ + elementType: 'div', + getSlotProps, + externalSlotProps, + ownerState, + }); + + expect(externalSlotProps.callCount).not.to.equal(0); + expect(externalSlotProps.args[0][0]).to.deep.equal(ownerState); + }); + + it('should not call externalSlotProps if skipResolvingSlotProps is true', () => { + const externalSlotProps = spy(); + + const getSlotProps = () => ({ + skipResolvingSlotProps: true, + }); + + callUseSlotProps({ + elementType: 'div', + getSlotProps, + externalSlotProps, + skipResolvingSlotProps: true, + ownerState: undefined, + }); + + expect(externalSlotProps.callCount).to.equal(0); + }); +}); diff --git a/packages/mui-material/src/utils/useSlotProps.ts b/packages/mui-material/src/utils/useSlotProps.ts new file mode 100644 index 00000000000000..10e40630768283 --- /dev/null +++ b/packages/mui-material/src/utils/useSlotProps.ts @@ -0,0 +1,113 @@ +'use client'; +import * as React from 'react'; +import { unstable_useForkRef as useForkRef } from '@mui/utils'; +import { appendOwnerState, AppendOwnerStateReturnType } from './appendOwnerState'; +import { + mergeSlotProps, + MergeSlotPropsParameters, + MergeSlotPropsResult, + WithCommonProps, +} from './mergeSlotProps'; +import { resolveComponentProps } from './resolveComponentProps'; + +export type UseSlotPropsParameters< + ElementType extends React.ElementType, + SlotProps, + ExternalForwardedProps, + ExternalSlotProps, + AdditionalProps, + OwnerState, +> = Omit< + MergeSlotPropsParameters, + 'externalSlotProps' +> & { + /** + * The type of the component used in the slot. + */ + elementType: ElementType | undefined; + /** + * The `slotProps.*` of the Base UI component. + */ + externalSlotProps: + | ExternalSlotProps + | ((ownerState: OwnerState) => ExternalSlotProps) + | undefined; + /** + * The ownerState of the Base UI component. + */ + ownerState: OwnerState; + /** + * Set to true if the slotProps callback should receive more props. + */ + skipResolvingSlotProps?: boolean; +}; + +export type UseSlotPropsResult< + ElementType extends React.ElementType, + SlotProps, + AdditionalProps, + OwnerState, +> = AppendOwnerStateReturnType< + ElementType, + MergeSlotPropsResult['props'] & { + ref: ((instance: any | null) => void) | null; + }, + OwnerState +>; + +/** + * @ignore - do not document. + * Builds the props to be passed into the slot of an unstyled component. + * It merges the internal props of the component with the ones supplied by the user, allowing to customize the behavior. + * If the slot component is not a host component, it also merges in the `ownerState`. + * + * @param parameters.getSlotProps - A function that returns the props to be passed to the slot component. + */ +export function useSlotProps< + ElementType extends React.ElementType, + SlotProps, + AdditionalProps, + OwnerState, +>( + parameters: UseSlotPropsParameters< + ElementType, + SlotProps, + object, + WithCommonProps>, + AdditionalProps, + OwnerState + >, +) { + const { + elementType, + externalSlotProps, + ownerState, + skipResolvingSlotProps = false, + ...rest + } = parameters; + const resolvedComponentsProps = skipResolvingSlotProps + ? {} + : resolveComponentProps(externalSlotProps, ownerState); + const { props: mergedProps, internalRef } = mergeSlotProps({ + ...rest, + externalSlotProps: resolvedComponentsProps, + }); + + const ref = useForkRef( + internalRef, + resolvedComponentsProps?.ref, + parameters.additionalProps?.ref, + ) as ((instance: any | null) => void) | null; + + const props: UseSlotPropsResult = + appendOwnerState( + elementType, + { + ...mergedProps, + ref, + }, + ownerState, + ); + + return props; +} From 44b6dc77d62c000a81bf3f6f401497d2d1e66c6f Mon Sep 17 00:00:00 2001 From: mnajdova Date: Thu, 11 Jul 2024 10:21:25 +0200 Subject: [PATCH 02/24] move composeClasses --- packages/mui-material/src/index.d.ts | 2 +- packages/mui-material/src/index.js | 2 +- .../src/utils/composeClasses.test.ts | 58 +++++++++++++++++++ .../mui-material/src/utils/composeClasses.ts | 30 ++++++++++ .../mui-material/src/utils/useSlot.test.tsx | 2 +- 5 files changed, 91 insertions(+), 3 deletions(-) create mode 100644 packages/mui-material/src/utils/composeClasses.test.ts create mode 100644 packages/mui-material/src/utils/composeClasses.ts diff --git a/packages/mui-material/src/index.d.ts b/packages/mui-material/src/index.d.ts index 5e389716c185bb..3be313402dc705 100644 --- a/packages/mui-material/src/index.d.ts +++ b/packages/mui-material/src/index.d.ts @@ -481,7 +481,7 @@ export * from './GlobalStyles'; */ export { StyledEngineProvider } from './styles'; -export { unstable_composeClasses } from '@mui/base/composeClasses'; +export { default as unstable_composeClasses } from './utils/composeClasses'; export { default as generateUtilityClass } from './generateUtilityClass'; export * from './generateUtilityClass'; diff --git a/packages/mui-material/src/index.js b/packages/mui-material/src/index.js index 3a5370b4436098..41566d57bb0fca 100644 --- a/packages/mui-material/src/index.js +++ b/packages/mui-material/src/index.js @@ -409,7 +409,7 @@ export { default as useAutocomplete } from './useAutocomplete'; export { default as GlobalStyles } from './GlobalStyles'; export * from './GlobalStyles'; -export { unstable_composeClasses } from '@mui/base/composeClasses'; +export { default as unstable_composeClasses } from './utils/composeClasses'; export { default as generateUtilityClass } from './generateUtilityClass'; export * from './generateUtilityClass'; diff --git a/packages/mui-material/src/utils/composeClasses.test.ts b/packages/mui-material/src/utils/composeClasses.test.ts new file mode 100644 index 00000000000000..310c217bf8414a --- /dev/null +++ b/packages/mui-material/src/utils/composeClasses.test.ts @@ -0,0 +1,58 @@ +import { expect } from 'chai'; +import { unstable_composeClasses as composeClasses } from '@mui/utils'; + +describe('composeClasses', () => { + it('should generate the classes based on the slots', () => { + expect( + composeClasses( + { + root: ['root', 'standard'], + slot: ['slot'], + }, + (slot) => `MuiTest-${slot}`, + undefined, + ), + ).to.deep.equal({ + root: 'MuiTest-root MuiTest-standard', + slot: 'MuiTest-slot', + }); + }); + + it('should consider classes if available', () => { + expect( + composeClasses( + { + root: ['root', 'standard'], + slot: ['slot'], + }, + (slot) => `MuiTest-${slot}`, + { + standard: 'standardOverride', + slot: 'slotOverride', + }, + ), + ).to.deep.equal({ + root: 'MuiTest-root MuiTest-standard standardOverride', + slot: 'MuiTest-slot slotOverride', + }); + }); + + it('should ignore false values', () => { + expect( + composeClasses( + { + root: ['root', false, 'standard'], + slot: ['slot'], + }, + (slot) => `MuiTest-${slot}`, + { + standard: 'standardOverride', + slot: 'slotOverride', + }, + ), + ).to.deep.equal({ + root: 'MuiTest-root MuiTest-standard standardOverride', + slot: 'MuiTest-slot slotOverride', + }); + }); +}); diff --git a/packages/mui-material/src/utils/composeClasses.ts b/packages/mui-material/src/utils/composeClasses.ts new file mode 100644 index 00000000000000..259ed7a05534c3 --- /dev/null +++ b/packages/mui-material/src/utils/composeClasses.ts @@ -0,0 +1,30 @@ +export default function composeClasses( + slots: Record>, + getUtilityClass: (slot: string) => string, + classes: Record | undefined = undefined, +): Record { + const output: Record = {} as any; + + Object.keys(slots).forEach( + // `Object.keys(slots)` can't be wider than `T` because we infer `T` from `slots`. + // @ts-expect-error https://github.com/microsoft/TypeScript/pull/12253#issuecomment-263132208 + (slot: ClassKey) => { + output[slot] = slots[slot] + .reduce((acc, key) => { + if (key) { + const utilityClass = getUtilityClass(key); + if (utilityClass !== '') { + acc.push(utilityClass); + } + if (classes && classes[key]) { + acc.push(classes[key]); + } + } + return acc; + }, [] as string[]) + .join(' '); + }, + ); + + return output; +} diff --git a/packages/mui-material/src/utils/useSlot.test.tsx b/packages/mui-material/src/utils/useSlot.test.tsx index 7c542ef4c60c6d..413df1a9f57022 100644 --- a/packages/mui-material/src/utils/useSlot.test.tsx +++ b/packages/mui-material/src/utils/useSlot.test.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { expect } from 'chai'; import { createRenderer } from '@mui/internal-test-utils'; -import { Popper } from '@mui/base/Popper'; +import Popper from '@mui/material/Popper'; import { styled } from '../styles'; import { SlotProps } from './types'; import useSlot from './useSlot'; From 2224463c3e497518763f3549e5e364a5d605fe4f Mon Sep 17 00:00:00 2001 From: mnajdova Date: Thu, 11 Jul 2024 10:36:53 +0200 Subject: [PATCH 03/24] Move Autocomplete & Badge dependencies --- .../src/Autocomplete/Autocomplete.d.ts | 5 +- .../src/Autocomplete/Autocomplete.js | 2 +- packages/mui-material/src/Badge/Badge.js | 2 +- packages/mui-material/src/Badge/useBadge.ts | 46 + .../mui-material/src/Badge/useBadge.types.ts | 40 + .../src/useAutocomplete/useAutocomplete.d.ts | 462 ++++++- .../src/useAutocomplete/useAutocomplete.js | 1184 ++++++++++++++++- .../useAutocomplete/useAutocomplete.spec.ts | 184 +++ .../useAutocomplete/useAutocomplete.test.js | 380 ++++++ 9 files changed, 2296 insertions(+), 9 deletions(-) create mode 100644 packages/mui-material/src/Badge/useBadge.ts create mode 100644 packages/mui-material/src/Badge/useBadge.types.ts create mode 100644 packages/mui-material/src/useAutocomplete/useAutocomplete.spec.ts create mode 100644 packages/mui-material/src/useAutocomplete/useAutocomplete.test.js diff --git a/packages/mui-material/src/Autocomplete/Autocomplete.d.ts b/packages/mui-material/src/Autocomplete/Autocomplete.d.ts index b3cf3cb08df550..cf54873a5d7bdd 100644 --- a/packages/mui-material/src/Autocomplete/Autocomplete.d.ts +++ b/packages/mui-material/src/Autocomplete/Autocomplete.d.ts @@ -1,8 +1,7 @@ import * as React from 'react'; import { SxProps } from '@mui/system'; import { OverridableStringUnion } from '@mui/types'; -import { - useAutocomplete, +import useAutocomplete, { AutocompleteChangeDetails, AutocompleteChangeReason, AutocompleteCloseReason, @@ -11,7 +10,7 @@ import { createFilterOptions, UseAutocompleteProps, AutocompleteFreeSoloValueMapping, -} from '@mui/base'; +} from '../useAutocomplete'; import { IconButtonProps, InternalStandardProps as StandardProps, Theme } from '@mui/material'; import { ChipProps, ChipTypeMap } from '@mui/material/Chip'; import { PaperProps } from '@mui/material/Paper'; diff --git a/packages/mui-material/src/Autocomplete/Autocomplete.js b/packages/mui-material/src/Autocomplete/Autocomplete.js index d02f3daaa10c83..f8ec342be4eb33 100644 --- a/packages/mui-material/src/Autocomplete/Autocomplete.js +++ b/packages/mui-material/src/Autocomplete/Autocomplete.js @@ -4,7 +4,7 @@ import PropTypes from 'prop-types'; import clsx from 'clsx'; import integerPropType from '@mui/utils/integerPropType'; import chainPropTypes from '@mui/utils/chainPropTypes'; -import { useAutocomplete, createFilterOptions } from '@mui/base'; +import useAutocomplete, { createFilterOptions } from '../useAutocomplete'; import composeClasses from '@mui/utils/composeClasses'; import { alpha } from '@mui/system/colorManipulator'; import Popper from '../Popper'; diff --git a/packages/mui-material/src/Badge/Badge.js b/packages/mui-material/src/Badge/Badge.js index 33743ddac8fcfd..9c7196305f4677 100644 --- a/packages/mui-material/src/Badge/Badge.js +++ b/packages/mui-material/src/Badge/Badge.js @@ -4,7 +4,7 @@ import PropTypes from 'prop-types'; import clsx from 'clsx'; import usePreviousProps from '@mui/utils/usePreviousProps'; import composeClasses from '@mui/utils/composeClasses'; -import { useBadge } from '@mui/base/useBadge'; +import { useBadge } from './useBadge'; import { useSlotProps } from '../utils/useSlotProps'; import { styled } from '../zero-styled'; import { useDefaultProps } from '../DefaultPropsProvider'; diff --git a/packages/mui-material/src/Badge/useBadge.ts b/packages/mui-material/src/Badge/useBadge.ts new file mode 100644 index 00000000000000..29fbdc8cc99d89 --- /dev/null +++ b/packages/mui-material/src/Badge/useBadge.ts @@ -0,0 +1,46 @@ +'use client'; +import * as React from 'react'; +import { usePreviousProps } from '@mui/utils'; +import { UseBadgeParameters, UseBadgeReturnValue } from './useBadge.types'; + +/** + * + * Demos: + * + * - [Badge](https://next.mui.com/base-ui/react-badge/#hook) + * + * API: + * + * - [useBadge API](https://next.mui.com/base-ui/react-badge/hooks-api/#use-badge) + */ +export function useBadge(parameters: UseBadgeParameters): UseBadgeReturnValue { + const { + badgeContent: badgeContentProp, + invisible: invisibleProp = false, + max: maxProp = 99, + showZero = false, + } = parameters; + + const prevProps = usePreviousProps({ + badgeContent: badgeContentProp, + max: maxProp, + }); + + let invisible = invisibleProp; + + if (invisibleProp === false && badgeContentProp === 0 && !showZero) { + invisible = true; + } + + const { badgeContent, max = maxProp } = invisible ? prevProps : parameters; + + const displayValue: React.ReactNode = + badgeContent && Number(badgeContent) > max ? `${max}+` : badgeContent; + + return { + badgeContent, + invisible, + max, + displayValue, + }; +} diff --git a/packages/mui-material/src/Badge/useBadge.types.ts b/packages/mui-material/src/Badge/useBadge.types.ts new file mode 100644 index 00000000000000..be6ef34830c5f8 --- /dev/null +++ b/packages/mui-material/src/Badge/useBadge.types.ts @@ -0,0 +1,40 @@ +export interface UseBadgeParameters { + /** + * The content rendered within the badge. + */ + badgeContent?: React.ReactNode; + /** + * If `true`, the badge is invisible. + * @default false + */ + invisible?: boolean; + /** + * Max count to show. + * @default 99 + */ + max?: number; + /** + * Controls whether the badge is hidden when `badgeContent` is zero. + * @default false + */ + showZero?: boolean; +} + +export interface UseBadgeReturnValue { + /** + * Defines the content that's displayed inside the badge. + */ + badgeContent: React.ReactNode; + /** + * If `true`, the component will not be visible. + */ + invisible: boolean; + /** + * Maximum number to be displayed in the badge. + */ + max: number; + /** + * Value to be displayed in the badge. If `badgeContent` is greater than `max`, it will return `max+`. + */ + displayValue: React.ReactNode; +} diff --git a/packages/mui-material/src/useAutocomplete/useAutocomplete.d.ts b/packages/mui-material/src/useAutocomplete/useAutocomplete.d.ts index 6091303d93a923..b9286b06d7048b 100644 --- a/packages/mui-material/src/useAutocomplete/useAutocomplete.d.ts +++ b/packages/mui-material/src/useAutocomplete/useAutocomplete.d.ts @@ -1,2 +1,460 @@ -export { useAutocomplete as default } from '@mui/base/useAutocomplete'; -export * from '@mui/base/useAutocomplete'; +import * as React from 'react'; + +export interface CreateFilterOptionsConfig { + ignoreAccents?: boolean; + ignoreCase?: boolean; + limit?: number; + matchFrom?: 'any' | 'start'; + stringify?: (option: Value) => string; + trim?: boolean; +} + +export interface FilterOptionsState { + inputValue: string; + getOptionLabel: (option: Value) => string; +} + +export interface AutocompleteGroupedOption { + key: number; + index: number; + group: string; + options: Value[]; +} + +export function createFilterOptions( + config?: CreateFilterOptionsConfig, +): (options: Value[], state: FilterOptionsState) => Value[]; + +export type AutocompleteFreeSoloValueMapping = FreeSolo extends true ? string : never; + +export type AutocompleteValue = Multiple extends true + ? Array> + : DisableClearable extends true + ? NonNullable> + : Value | null | AutocompleteFreeSoloValueMapping; + +export interface UseAutocompleteProps< + Value, + Multiple extends boolean | undefined, + DisableClearable extends boolean | undefined, + FreeSolo extends boolean | undefined, +> { + /** + * @internal The prefix of the state class name, temporary for Joy UI + * @default 'Mui' + */ + unstable_classNamePrefix?: string; + /** + * @internal + * Temporary for Joy UI because the parent listbox is the document object + * TODO v6: Normalize the logic and remove this param. + */ + unstable_isActiveElementInListbox?: (listbox: React.RefObject) => boolean; + /** + * If `true`, the portion of the selected suggestion that the user hasn't typed, + * known as the completion string, appears inline after the input cursor in the textbox. + * The inline completion string is visually highlighted and has a selected state. + * @default false + */ + autoComplete?: boolean; + /** + * If `true`, the first option is automatically highlighted. + * @default false + */ + autoHighlight?: boolean; + /** + * If `true`, the selected option becomes the value of the input + * when the Autocomplete loses focus unless the user chooses + * a different option or changes the character string in the input. + * + * When using the `freeSolo` mode, the typed value will be the input value + * if the Autocomplete loses focus without highlighting an option. + * @default false + */ + autoSelect?: boolean; + /** + * Control if the input should be blurred when an option is selected: + * + * - `false` the input is not blurred. + * - `true` the input is always blurred. + * - `touch` the input is blurred after a touch event. + * - `mouse` the input is blurred after a mouse event. + * @default false + */ + blurOnSelect?: 'touch' | 'mouse' | true | false; + /** + * If `true`, the input's text is cleared on blur if no value is selected. + * + * Set it to `true` if you want to help the user enter a new value. + * Set it to `false` if you want to help the user resume their search. + * @default !props.freeSolo + */ + clearOnBlur?: boolean; + /** + * If `true`, clear all values when the user presses escape and the popup is closed. + * @default false + */ + clearOnEscape?: boolean; + /** + * The component name that is using this hook. Used for warnings. + */ + componentName?: string; + /** + * The default value. Use when the component is not controlled. + * @default props.multiple ? [] : null + */ + defaultValue?: AutocompleteValue; + /** + * If `true`, the input can't be cleared. + * @default false + */ + disableClearable?: DisableClearable; + /** + * If `true`, the popup won't close when a value is selected. + * @default false + */ + disableCloseOnSelect?: boolean; + /** + * If `true`, the component is disabled. + * @default false + */ + disabled?: boolean; + /** + * If `true`, will allow focus on disabled items. + * @default false + */ + disabledItemsFocusable?: boolean; + /** + * If `true`, the list box in the popup will not wrap focus. + * @default false + */ + disableListWrap?: boolean; + /** + * A function that determines the filtered options to be rendered on search. + * + * @default createFilterOptions() + * @param {Value[]} options The options to render. + * @param {object} state The state of the component. + * @returns {Value[]} + */ + filterOptions?: (options: Value[], state: FilterOptionsState) => Value[]; + /** + * If `true`, hide the selected options from the list box. + * @default false + */ + filterSelectedOptions?: boolean; + /** + * If `true`, the Autocomplete is free solo, meaning that the user input is not bound to provided options. + * @default false + */ + freeSolo?: FreeSolo; + /** + * Used to determine the disabled state for a given option. + * + * @param {Value} option The option to test. + * @returns {boolean} + */ + getOptionDisabled?: (option: Value) => boolean; + /** + * Used to determine the key for a given option. + * This can be useful when the labels of options are not unique (since labels are used as keys by default). + * + * @param {Value} option The option to get the key for. + * @returns {string | number} + */ + getOptionKey?: (option: Value | AutocompleteFreeSoloValueMapping) => string | number; + /** + * Used to determine the string value for a given option. + * It's used to fill the input (and the list box options if `renderOption` is not provided). + * + * If used in free solo mode, it must accept both the type of the options and a string. + * + * @param {Value} option + * @returns {string} + * @default (option) => option.label ?? option + */ + getOptionLabel?: (option: Value | AutocompleteFreeSoloValueMapping) => string; + /** + * If provided, the options will be grouped under the returned string. + * The groupBy value is also used as the text for group headings when `renderGroup` is not provided. + * + * @param {Value} options The options to group. + * @returns {string} + */ + groupBy?: (option: Value) => string; + + /** + * If `true`, the component handles the "Home" and "End" keys when the popup is open. + * It should move focus to the first option and last option, respectively. + * @default !props.freeSolo + */ + handleHomeEndKeys?: boolean; + /** + * This prop is used to help implement the accessibility logic. + * If you don't provide an id it will fall back to a randomly generated one. + */ + id?: string; + /** + * If `true`, the highlight can move to the input. + * @default false + */ + includeInputInList?: boolean; + /** + * The input value. + */ + inputValue?: string; + /** + * Used to determine if the option represents the given value. + * Uses strict equality by default. + * ⚠️ Both arguments need to be handled, an option can only match with one value. + * + * @param {Value} option The option to test. + * @param {Value} value The value to test against. + * @returns {boolean} + */ + isOptionEqualToValue?: (option: Value, value: Value) => boolean; + /** + * If `true`, `value` must be an array and the menu will support multiple selections. + * @default false + */ + multiple?: Multiple; + /** + * Callback fired when the value changes. + * + * @param {React.SyntheticEvent} event The event source of the callback. + * @param {Value|Value[]} value The new value of the component. + * @param {string} reason One of "createOption", "selectOption", "removeOption", "blur" or "clear". + * @param {string} [details] + */ + onChange?: ( + event: React.SyntheticEvent, + value: AutocompleteValue, + reason: AutocompleteChangeReason, + details?: AutocompleteChangeDetails, + ) => void; + /** + * Callback fired when the popup requests to be closed. + * Use in controlled mode (see open). + * + * @param {React.SyntheticEvent} event The event source of the callback. + * @param {string} reason Can be: `"toggleInput"`, `"escape"`, `"selectOption"`, `"removeOption"`, `"blur"`. + */ + onClose?: (event: React.SyntheticEvent, reason: AutocompleteCloseReason) => void; + /** + * Callback fired when the highlight option changes. + * + * @param {React.SyntheticEvent} event The event source of the callback. + * @param {Value} option The highlighted option. + * @param {string} reason Can be: `"keyboard"`, `"auto"`, `"mouse"`, `"touch"`. + */ + onHighlightChange?: ( + event: React.SyntheticEvent, + option: Value | null, + reason: AutocompleteHighlightChangeReason, + ) => void; + /** + * Callback fired when the input value changes. + * + * @param {React.SyntheticEvent} event The event source of the callback. + * @param {string} value The new value of the text input. + * @param {string} reason Can be: `"input"` (user input), `"reset"` (programmatic change), `"clear"`. + */ + onInputChange?: ( + event: React.SyntheticEvent, + value: string, + reason: AutocompleteInputChangeReason, + ) => void; + /** + * Callback fired when the popup requests to be opened. + * Use in controlled mode (see open). + * + * @param {React.SyntheticEvent} event The event source of the callback. + */ + onOpen?: (event: React.SyntheticEvent) => void; + /** + * If `true`, the component is shown. + */ + open?: boolean; + /** + * If `true`, the popup will open on input focus. + * @default false + */ + openOnFocus?: boolean; + /** + * Array of options. + */ + options: ReadonlyArray; + /** + * If `true`, the component becomes readonly. It is also supported for multiple tags where the tag cannot be deleted. + * @default false + */ + readOnly?: boolean; + /** + * If `true`, the input's text is selected on focus. + * It helps the user clear the selected value. + * @default !props.freeSolo + */ + selectOnFocus?: boolean; + /** + * The value of the autocomplete. + * + * The value must have reference equality with the option in order to be selected. + * You can customize the equality behavior with the `isOptionEqualToValue` prop. + */ + value?: AutocompleteValue; +} + +export interface UseAutocompleteParameters< + Value, + Multiple extends boolean | undefined, + DisableClearable extends boolean | undefined, + FreeSolo extends boolean | undefined, +> extends UseAutocompleteProps {} + +export type AutocompleteHighlightChangeReason = 'keyboard' | 'mouse' | 'auto' | 'touch'; + +export type AutocompleteChangeReason = + | 'createOption' + | 'selectOption' + | 'removeOption' + | 'clear' + | 'blur'; +export interface AutocompleteChangeDetails { + option: Value; +} +export type AutocompleteCloseReason = + | 'createOption' + | 'toggleInput' + | 'escape' + | 'selectOption' + | 'removeOption' + | 'blur'; +export type AutocompleteInputChangeReason = 'input' | 'reset' | 'clear'; + +export type AutocompleteGetTagProps = ({ index }: { index: number }) => { + key: number; + 'data-tag-index': number; + tabIndex: -1; + onDelete: (event: any) => void; +}; +/** + * + * Demos: + * + * - [Autocomplete](https://next.mui.com/base-ui/react-autocomplete/#hook) + * + * API: + * + * - [useAutocomplete API](https://next.mui.com/base-ui/react-autocomplete/hooks-api/#use-autocomplete) + */ +export function useAutocomplete< + Value, + Multiple extends boolean | undefined = false, + DisableClearable extends boolean | undefined = false, + FreeSolo extends boolean | undefined = false, +>( + props: UseAutocompleteProps, +): UseAutocompleteReturnValue; + +export interface UseAutocompleteRenderedOption { + option: Value; + index: number; +} + +export interface UseAutocompleteReturnValue< + Value, + Multiple extends boolean | undefined = false, + DisableClearable extends boolean | undefined = false, + FreeSolo extends boolean | undefined = false, +> { + /** + * Resolver for the root slot's props. + * @param externalProps props for the root slot + * @returns props that should be spread on the root slot + */ + getRootProps: (externalProps?: any) => React.HTMLAttributes; + /** + * Resolver for the input element's props. + * @returns props that should be spread on the input element + */ + getInputProps: () => React.InputHTMLAttributes & { + ref: React.Ref; + }; + /** + * Resolver for the input label element's props. + * @returns props that should be spread on the input label element + */ + getInputLabelProps: () => Omit, 'color'>; + /** + * Resolver for the `clear` button element's props. + * @returns props that should be spread on the *clear* button element + */ + getClearProps: () => React.HTMLAttributes; + /** + * Resolver for the popup icon's props. + * @returns props that should be spread on the popup icon + */ + getPopupIndicatorProps: () => React.HTMLAttributes; + /** + * A tag props getter. + */ + getTagProps: AutocompleteGetTagProps; + /** + * Resolver for the listbox component's props. + * @returns props that should be spread on the listbox component + */ + getListboxProps: () => React.HTMLAttributes; + /** + * Resolver for the rendered option element's props. + * @param renderedOption option rendered on the Autocomplete + * @returns props that should be spread on the li element + */ + getOptionProps: ( + renderedOption: UseAutocompleteRenderedOption, + ) => React.HTMLAttributes & { key: any }; + /** + * Id for the Autocomplete. + */ + id: string; + /** + * The input value. + */ + inputValue: string; + /** + * The value of the autocomplete. + */ + value: AutocompleteValue; + /** + * If `true`, the component input has some values. + */ + dirty: boolean; + /** + * If `true`, the listbox is being displayed. + */ + expanded: boolean; + /** + * If `true`, the popup is open on the component. + */ + popupOpen: boolean; + /** + * If `true`, the component is focused. + */ + focused: boolean; + /** + * An HTML element that is used to set the position of the component. + */ + anchorEl: null | HTMLElement; + /** + * Setter for the component `anchorEl`. + * @returns function for setting `anchorEl` + */ + setAnchorEl: () => void; + /** + * Index of the focused tag for the component. + */ + focusedTag: number; + /** + * The options to render. It's either `Value[]` or `AutocompleteGroupedOption[]` if the groupBy prop is provided. + */ + groupedOptions: Value[] | Array>; +} diff --git a/packages/mui-material/src/useAutocomplete/useAutocomplete.js b/packages/mui-material/src/useAutocomplete/useAutocomplete.js index a8f55d5c818452..1373b4b26dd7fd 100644 --- a/packages/mui-material/src/useAutocomplete/useAutocomplete.js +++ b/packages/mui-material/src/useAutocomplete/useAutocomplete.js @@ -1,3 +1,1183 @@ 'use client'; -export { useAutocomplete as default } from '@mui/base/useAutocomplete'; -export * from '@mui/base/useAutocomplete'; +/* eslint-disable no-constant-condition */ +import * as React from 'react'; +import { + unstable_setRef as setRef, + unstable_useEventCallback as useEventCallback, + unstable_useControlled as useControlled, + unstable_useId as useId, + usePreviousProps, +} from '@mui/utils'; + +// https://stackoverflow.com/questions/990904/remove-accents-diacritics-in-a-string-in-javascript +function stripDiacritics(string) { + return string.normalize('NFD').replace(/[\u0300-\u036f]/g, ''); +} + +export function createFilterOptions(config = {}) { + const { + ignoreAccents = true, + ignoreCase = true, + limit, + matchFrom = 'any', + stringify, + trim = false, + } = config; + + return (options, { inputValue, getOptionLabel }) => { + let input = trim ? inputValue.trim() : inputValue; + if (ignoreCase) { + input = input.toLowerCase(); + } + if (ignoreAccents) { + input = stripDiacritics(input); + } + + const filteredOptions = !input + ? options + : options.filter((option) => { + let candidate = (stringify || getOptionLabel)(option); + if (ignoreCase) { + candidate = candidate.toLowerCase(); + } + if (ignoreAccents) { + candidate = stripDiacritics(candidate); + } + + return matchFrom === 'start' + ? candidate.indexOf(input) === 0 + : candidate.indexOf(input) > -1; + }); + + return typeof limit === 'number' ? filteredOptions.slice(0, limit) : filteredOptions; + }; +} + +const defaultFilterOptions = createFilterOptions(); + +// Number of options to jump in list box when `Page Up` and `Page Down` keys are used. +const pageSize = 5; + +const defaultIsActiveElementInListbox = (listboxRef) => + listboxRef.current !== null && listboxRef.current.parentElement?.contains(document.activeElement); + +function useAutocomplete(props) { + const { + // eslint-disable-next-line @typescript-eslint/naming-convention + unstable_isActiveElementInListbox = defaultIsActiveElementInListbox, + // eslint-disable-next-line @typescript-eslint/naming-convention + unstable_classNamePrefix = 'Mui', + autoComplete = false, + autoHighlight = false, + autoSelect = false, + blurOnSelect = false, + clearOnBlur = !props.freeSolo, + clearOnEscape = false, + componentName = 'useAutocomplete', + defaultValue = props.multiple ? [] : null, + disableClearable = false, + disableCloseOnSelect = false, + disabled: disabledProp, + disabledItemsFocusable = false, + disableListWrap = false, + filterOptions = defaultFilterOptions, + filterSelectedOptions = false, + freeSolo = false, + getOptionDisabled, + getOptionKey, + getOptionLabel: getOptionLabelProp = (option) => option.label ?? option, + groupBy, + handleHomeEndKeys = !props.freeSolo, + id: idProp, + includeInputInList = false, + inputValue: inputValueProp, + isOptionEqualToValue = (option, value) => option === value, + multiple = false, + onChange, + onClose, + onHighlightChange, + onInputChange, + onOpen, + open: openProp, + openOnFocus = false, + options, + readOnly = false, + selectOnFocus = !props.freeSolo, + value: valueProp, + } = props; + + const id = useId(idProp); + + let getOptionLabel = getOptionLabelProp; + + getOptionLabel = (option) => { + const optionLabel = getOptionLabelProp(option); + if (typeof optionLabel !== 'string') { + if (process.env.NODE_ENV !== 'production') { + const erroneousReturn = + optionLabel === undefined ? 'undefined' : `${typeof optionLabel} (${optionLabel})`; + console.error( + `MUI: The \`getOptionLabel\` method of ${componentName} returned ${erroneousReturn} instead of a string for ${JSON.stringify( + option, + )}.`, + ); + } + return String(optionLabel); + } + return optionLabel; + }; + + const ignoreFocus = React.useRef(false); + const firstFocus = React.useRef(true); + const inputRef = React.useRef(null); + const listboxRef = React.useRef(null); + const [anchorEl, setAnchorEl] = React.useState(null); + + const [focusedTag, setFocusedTag] = React.useState(-1); + const defaultHighlighted = autoHighlight ? 0 : -1; + const highlightedIndexRef = React.useRef(defaultHighlighted); + + const [value, setValueState] = useControlled({ + controlled: valueProp, + default: defaultValue, + name: componentName, + }); + const [inputValue, setInputValueState] = useControlled({ + controlled: inputValueProp, + default: '', + name: componentName, + state: 'inputValue', + }); + + const [focused, setFocused] = React.useState(false); + + const resetInputValue = React.useCallback( + (event, newValue) => { + // retain current `inputValue` if new option isn't selected and `clearOnBlur` is false + // When `multiple` is enabled, `newValue` is an array of all selected items including the newly selected item + const isOptionSelected = multiple ? value.length < newValue.length : newValue !== null; + if (!isOptionSelected && !clearOnBlur) { + return; + } + let newInputValue; + if (multiple) { + newInputValue = ''; + } else if (newValue == null) { + newInputValue = ''; + } else { + const optionLabel = getOptionLabel(newValue); + newInputValue = typeof optionLabel === 'string' ? optionLabel : ''; + } + + if (inputValue === newInputValue) { + return; + } + + setInputValueState(newInputValue); + + if (onInputChange) { + onInputChange(event, newInputValue, 'reset'); + } + }, + [getOptionLabel, inputValue, multiple, onInputChange, setInputValueState, clearOnBlur, value], + ); + + const [open, setOpenState] = useControlled({ + controlled: openProp, + default: false, + name: componentName, + state: 'open', + }); + + const [inputPristine, setInputPristine] = React.useState(true); + + const inputValueIsSelectedValue = + !multiple && value != null && inputValue === getOptionLabel(value); + + const popupOpen = open && !readOnly; + + const filteredOptions = popupOpen + ? filterOptions( + options.filter((option) => { + if ( + filterSelectedOptions && + (multiple ? value : [value]).some( + (value2) => value2 !== null && isOptionEqualToValue(option, value2), + ) + ) { + return false; + } + return true; + }), + // we use the empty string to manipulate `filterOptions` to not filter any options + // i.e. the filter predicate always returns true + { + inputValue: inputValueIsSelectedValue && inputPristine ? '' : inputValue, + getOptionLabel, + }, + ) + : []; + + const previousProps = usePreviousProps({ + filteredOptions, + value, + inputValue, + }); + + React.useEffect(() => { + const valueChange = value !== previousProps.value; + + if (focused && !valueChange) { + return; + } + + // Only reset the input's value when freeSolo if the component's value changes. + if (freeSolo && !valueChange) { + return; + } + + resetInputValue(null, value); + }, [value, resetInputValue, focused, previousProps.value, freeSolo]); + + const listboxAvailable = open && filteredOptions.length > 0 && !readOnly; + + if (process.env.NODE_ENV !== 'production') { + if (value !== null && !freeSolo && options.length > 0) { + const missingValue = (multiple ? value : [value]).filter( + (value2) => !options.some((option) => isOptionEqualToValue(option, value2)), + ); + + if (missingValue.length > 0) { + console.warn( + [ + `MUI: The value provided to ${componentName} is invalid.`, + `None of the options match with \`${ + missingValue.length > 1 + ? JSON.stringify(missingValue) + : JSON.stringify(missingValue[0]) + }\`.`, + 'You can use the `isOptionEqualToValue` prop to customize the equality test.', + ].join('\n'), + ); + } + } + } + + const focusTag = useEventCallback((tagToFocus) => { + if (tagToFocus === -1) { + inputRef.current.focus(); + } else { + anchorEl.querySelector(`[data-tag-index="${tagToFocus}"]`).focus(); + } + }); + + // Ensure the focusedTag is never inconsistent + React.useEffect(() => { + if (multiple && focusedTag > value.length - 1) { + setFocusedTag(-1); + focusTag(-1); + } + }, [value, multiple, focusedTag, focusTag]); + + function validOptionIndex(index, direction) { + if (!listboxRef.current || index < 0 || index >= filteredOptions.length) { + return -1; + } + + let nextFocus = index; + + while (true) { + const option = listboxRef.current.querySelector(`[data-option-index="${nextFocus}"]`); + + // Same logic as MenuList.js + const nextFocusDisabled = disabledItemsFocusable + ? false + : !option || option.disabled || option.getAttribute('aria-disabled') === 'true'; + + if (option && option.hasAttribute('tabindex') && !nextFocusDisabled) { + // The next option is available + return nextFocus; + } + + // The next option is disabled, move to the next element. + // with looped index + if (direction === 'next') { + nextFocus = (nextFocus + 1) % filteredOptions.length; + } else { + nextFocus = (nextFocus - 1 + filteredOptions.length) % filteredOptions.length; + } + + // We end up with initial index, that means we don't have available options. + // All of them are disabled + if (nextFocus === index) { + return -1; + } + } + } + + const setHighlightedIndex = useEventCallback(({ event, index, reason = 'auto' }) => { + highlightedIndexRef.current = index; + + // does the index exist? + if (index === -1) { + inputRef.current.removeAttribute('aria-activedescendant'); + } else { + inputRef.current.setAttribute('aria-activedescendant', `${id}-option-${index}`); + } + + if (onHighlightChange) { + onHighlightChange(event, index === -1 ? null : filteredOptions[index], reason); + } + + if (!listboxRef.current) { + return; + } + + const prev = listboxRef.current.querySelector( + `[role="option"].${unstable_classNamePrefix}-focused`, + ); + if (prev) { + prev.classList.remove(`${unstable_classNamePrefix}-focused`); + prev.classList.remove(`${unstable_classNamePrefix}-focusVisible`); + } + + let listboxNode = listboxRef.current; + if (listboxRef.current.getAttribute('role') !== 'listbox') { + listboxNode = listboxRef.current.parentElement.querySelector('[role="listbox"]'); + } + + // "No results" + if (!listboxNode) { + return; + } + + if (index === -1) { + listboxNode.scrollTop = 0; + return; + } + + const option = listboxRef.current.querySelector(`[data-option-index="${index}"]`); + + if (!option) { + return; + } + + option.classList.add(`${unstable_classNamePrefix}-focused`); + if (reason === 'keyboard') { + option.classList.add(`${unstable_classNamePrefix}-focusVisible`); + } + + // Scroll active descendant into view. + // Logic copied from https://www.w3.org/WAI/content-assets/wai-aria-practices/patterns/combobox/examples/js/select-only.js + // In case of mouse clicks and touch (in mobile devices) we avoid scrolling the element and keep both behaviors same. + // Consider this API instead once it has a better browser support: + // .scrollIntoView({ scrollMode: 'if-needed', block: 'nearest' }); + if ( + listboxNode.scrollHeight > listboxNode.clientHeight && + reason !== 'mouse' && + reason !== 'touch' + ) { + const element = option; + + const scrollBottom = listboxNode.clientHeight + listboxNode.scrollTop; + const elementBottom = element.offsetTop + element.offsetHeight; + if (elementBottom > scrollBottom) { + listboxNode.scrollTop = elementBottom - listboxNode.clientHeight; + } else if ( + element.offsetTop - element.offsetHeight * (groupBy ? 1.3 : 0) < + listboxNode.scrollTop + ) { + listboxNode.scrollTop = element.offsetTop - element.offsetHeight * (groupBy ? 1.3 : 0); + } + } + }); + + const changeHighlightedIndex = useEventCallback( + ({ event, diff, direction = 'next', reason = 'auto' }) => { + if (!popupOpen) { + return; + } + + const getNextIndex = () => { + const maxIndex = filteredOptions.length - 1; + + if (diff === 'reset') { + return defaultHighlighted; + } + + if (diff === 'start') { + return 0; + } + + if (diff === 'end') { + return maxIndex; + } + + const newIndex = highlightedIndexRef.current + diff; + + if (newIndex < 0) { + if (newIndex === -1 && includeInputInList) { + return -1; + } + + if ((disableListWrap && highlightedIndexRef.current !== -1) || Math.abs(diff) > 1) { + return 0; + } + + return maxIndex; + } + + if (newIndex > maxIndex) { + if (newIndex === maxIndex + 1 && includeInputInList) { + return -1; + } + + if (disableListWrap || Math.abs(diff) > 1) { + return maxIndex; + } + + return 0; + } + + return newIndex; + }; + + const nextIndex = validOptionIndex(getNextIndex(), direction); + setHighlightedIndex({ index: nextIndex, reason, event }); + + // Sync the content of the input with the highlighted option. + if (autoComplete && diff !== 'reset') { + if (nextIndex === -1) { + inputRef.current.value = inputValue; + } else { + const option = getOptionLabel(filteredOptions[nextIndex]); + inputRef.current.value = option; + + // The portion of the selected suggestion that has not been typed by the user, + // a completion string, appears inline after the input cursor in the textbox. + const index = option.toLowerCase().indexOf(inputValue.toLowerCase()); + if (index === 0 && inputValue.length > 0) { + inputRef.current.setSelectionRange(inputValue.length, option.length); + } + } + } + }, + ); + + const getPreviousHighlightedOptionIndex = () => { + const isSameValue = (value1, value2) => { + const label1 = value1 ? getOptionLabel(value1) : ''; + const label2 = value2 ? getOptionLabel(value2) : ''; + return label1 === label2; + }; + + if ( + highlightedIndexRef.current !== -1 && + previousProps.filteredOptions && + previousProps.filteredOptions.length !== filteredOptions.length && + previousProps.inputValue === inputValue && + (multiple + ? value.length === previousProps.value.length && + previousProps.value.every((val, i) => getOptionLabel(value[i]) === getOptionLabel(val)) + : isSameValue(previousProps.value, value)) + ) { + const previousHighlightedOption = previousProps.filteredOptions[highlightedIndexRef.current]; + + if (previousHighlightedOption) { + return filteredOptions.findIndex((option) => { + return getOptionLabel(option) === getOptionLabel(previousHighlightedOption); + }); + } + } + return -1; + }; + + const syncHighlightedIndex = React.useCallback(() => { + if (!popupOpen) { + return; + } + + // Check if the previously highlighted option still exists in the updated filtered options list and if the value and inputValue haven't changed + // If it exists and the value and the inputValue haven't changed, just update its index, otherwise continue execution + const previousHighlightedOptionIndex = getPreviousHighlightedOptionIndex(); + if (previousHighlightedOptionIndex !== -1) { + highlightedIndexRef.current = previousHighlightedOptionIndex; + return; + } + + const valueItem = multiple ? value[0] : value; + + // The popup is empty, reset + if (filteredOptions.length === 0 || valueItem == null) { + changeHighlightedIndex({ diff: 'reset' }); + return; + } + + if (!listboxRef.current) { + return; + } + + // Synchronize the value with the highlighted index + if (valueItem != null) { + const currentOption = filteredOptions[highlightedIndexRef.current]; + + // Keep the current highlighted index if possible + if ( + multiple && + currentOption && + value.findIndex((val) => isOptionEqualToValue(currentOption, val)) !== -1 + ) { + return; + } + + const itemIndex = filteredOptions.findIndex((optionItem) => + isOptionEqualToValue(optionItem, valueItem), + ); + if (itemIndex === -1) { + changeHighlightedIndex({ diff: 'reset' }); + } else { + setHighlightedIndex({ index: itemIndex }); + } + return; + } + + // Prevent the highlighted index to leak outside the boundaries. + if (highlightedIndexRef.current >= filteredOptions.length - 1) { + setHighlightedIndex({ index: filteredOptions.length - 1 }); + return; + } + + // Restore the focus to the previous index. + setHighlightedIndex({ index: highlightedIndexRef.current }); + // Ignore filteredOptions (and options, isOptionEqualToValue, getOptionLabel) not to break the scroll position + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + // Only sync the highlighted index when the option switch between empty and not + filteredOptions.length, + // Don't sync the highlighted index with the value when multiple + // eslint-disable-next-line react-hooks/exhaustive-deps + multiple ? false : value, + filterSelectedOptions, + changeHighlightedIndex, + setHighlightedIndex, + popupOpen, + inputValue, + multiple, + ]); + + const handleListboxRef = useEventCallback((node) => { + setRef(listboxRef, node); + + if (!node) { + return; + } + + syncHighlightedIndex(); + }); + + if (process.env.NODE_ENV !== 'production') { + // eslint-disable-next-line react-hooks/rules-of-hooks + React.useEffect(() => { + if (!inputRef.current || inputRef.current.nodeName !== 'INPUT') { + if (inputRef.current && inputRef.current.nodeName === 'TEXTAREA') { + console.warn( + [ + `A textarea element was provided to ${componentName} where input was expected.`, + `This is not a supported scenario but it may work under certain conditions.`, + `A textarea keyboard navigation may conflict with Autocomplete controls (for example enter and arrow keys).`, + `Make sure to test keyboard navigation and add custom event handlers if necessary.`, + ].join('\n'), + ); + } else { + console.error( + [ + `MUI: Unable to find the input element. It was resolved to ${inputRef.current} while an HTMLInputElement was expected.`, + `Instead, ${componentName} expects an input element.`, + '', + componentName === 'useAutocomplete' + ? 'Make sure you have bound getInputProps correctly and that the normal ref/effect resolutions order is guaranteed.' + : 'Make sure you have customized the input component correctly.', + ].join('\n'), + ); + } + } + }, [componentName]); + } + + React.useEffect(() => { + syncHighlightedIndex(); + }, [syncHighlightedIndex]); + + const handleOpen = (event) => { + if (open) { + return; + } + + setOpenState(true); + setInputPristine(true); + + if (onOpen) { + onOpen(event); + } + }; + + const handleClose = (event, reason) => { + if (!open) { + return; + } + + setOpenState(false); + + if (onClose) { + onClose(event, reason); + } + }; + + const handleValue = (event, newValue, reason, details) => { + if (multiple) { + if (value.length === newValue.length && value.every((val, i) => val === newValue[i])) { + return; + } + } else if (value === newValue) { + return; + } + + if (onChange) { + onChange(event, newValue, reason, details); + } + + setValueState(newValue); + }; + + const isTouch = React.useRef(false); + + const selectNewValue = (event, option, reasonProp = 'selectOption', origin = 'options') => { + let reason = reasonProp; + let newValue = option; + + if (multiple) { + newValue = Array.isArray(value) ? value.slice() : []; + + if (process.env.NODE_ENV !== 'production') { + const matches = newValue.filter((val) => isOptionEqualToValue(option, val)); + + if (matches.length > 1) { + console.error( + [ + `MUI: The \`isOptionEqualToValue\` method of ${componentName} does not handle the arguments correctly.`, + `The component expects a single value to match a given option but found ${matches.length} matches.`, + ].join('\n'), + ); + } + } + + const itemIndex = newValue.findIndex((valueItem) => isOptionEqualToValue(option, valueItem)); + + if (itemIndex === -1) { + newValue.push(option); + } else if (origin !== 'freeSolo') { + newValue.splice(itemIndex, 1); + reason = 'removeOption'; + } + } + + resetInputValue(event, newValue); + + handleValue(event, newValue, reason, { option }); + if (!disableCloseOnSelect && (!event || (!event.ctrlKey && !event.metaKey))) { + handleClose(event, reason); + } + + if ( + blurOnSelect === true || + (blurOnSelect === 'touch' && isTouch.current) || + (blurOnSelect === 'mouse' && !isTouch.current) + ) { + inputRef.current.blur(); + } + }; + + function validTagIndex(index, direction) { + if (index === -1) { + return -1; + } + + let nextFocus = index; + + while (true) { + // Out of range + if ( + (direction === 'next' && nextFocus === value.length) || + (direction === 'previous' && nextFocus === -1) + ) { + return -1; + } + + const option = anchorEl.querySelector(`[data-tag-index="${nextFocus}"]`); + + // Same logic as MenuList.js + if ( + !option || + !option.hasAttribute('tabindex') || + option.disabled || + option.getAttribute('aria-disabled') === 'true' + ) { + nextFocus += direction === 'next' ? 1 : -1; + } else { + return nextFocus; + } + } + } + + const handleFocusTag = (event, direction) => { + if (!multiple) { + return; + } + + if (inputValue === '') { + handleClose(event, 'toggleInput'); + } + + let nextTag = focusedTag; + + if (focusedTag === -1) { + if (inputValue === '' && direction === 'previous') { + nextTag = value.length - 1; + } + } else { + nextTag += direction === 'next' ? 1 : -1; + + if (nextTag < 0) { + nextTag = 0; + } + + if (nextTag === value.length) { + nextTag = -1; + } + } + + nextTag = validTagIndex(nextTag, direction); + + setFocusedTag(nextTag); + focusTag(nextTag); + }; + + const handleClear = (event) => { + ignoreFocus.current = true; + setInputValueState(''); + + if (onInputChange) { + onInputChange(event, '', 'clear'); + } + + handleValue(event, multiple ? [] : null, 'clear'); + }; + + const handleKeyDown = (other) => (event) => { + if (other.onKeyDown) { + other.onKeyDown(event); + } + + if (event.defaultMuiPrevented) { + return; + } + + if (focusedTag !== -1 && ['ArrowLeft', 'ArrowRight'].indexOf(event.key) === -1) { + setFocusedTag(-1); + focusTag(-1); + } + + // Wait until IME is settled. + if (event.which !== 229) { + switch (event.key) { + case 'Home': + if (popupOpen && handleHomeEndKeys) { + // Prevent scroll of the page + event.preventDefault(); + changeHighlightedIndex({ diff: 'start', direction: 'next', reason: 'keyboard', event }); + } + break; + case 'End': + if (popupOpen && handleHomeEndKeys) { + // Prevent scroll of the page + event.preventDefault(); + changeHighlightedIndex({ + diff: 'end', + direction: 'previous', + reason: 'keyboard', + event, + }); + } + break; + case 'PageUp': + // Prevent scroll of the page + event.preventDefault(); + changeHighlightedIndex({ + diff: -pageSize, + direction: 'previous', + reason: 'keyboard', + event, + }); + handleOpen(event); + break; + case 'PageDown': + // Prevent scroll of the page + event.preventDefault(); + changeHighlightedIndex({ diff: pageSize, direction: 'next', reason: 'keyboard', event }); + handleOpen(event); + break; + case 'ArrowDown': + // Prevent cursor move + event.preventDefault(); + changeHighlightedIndex({ diff: 1, direction: 'next', reason: 'keyboard', event }); + handleOpen(event); + break; + case 'ArrowUp': + // Prevent cursor move + event.preventDefault(); + changeHighlightedIndex({ diff: -1, direction: 'previous', reason: 'keyboard', event }); + handleOpen(event); + break; + case 'ArrowLeft': + handleFocusTag(event, 'previous'); + break; + case 'ArrowRight': + handleFocusTag(event, 'next'); + break; + case 'Enter': + if (highlightedIndexRef.current !== -1 && popupOpen) { + const option = filteredOptions[highlightedIndexRef.current]; + const disabled = getOptionDisabled ? getOptionDisabled(option) : false; + + // Avoid early form validation, let the end-users continue filling the form. + event.preventDefault(); + + if (disabled) { + return; + } + + selectNewValue(event, option, 'selectOption'); + + // Move the selection to the end. + if (autoComplete) { + inputRef.current.setSelectionRange( + inputRef.current.value.length, + inputRef.current.value.length, + ); + } + } else if (freeSolo && inputValue !== '' && inputValueIsSelectedValue === false) { + if (multiple) { + // Allow people to add new values before they submit the form. + event.preventDefault(); + } + selectNewValue(event, inputValue, 'createOption', 'freeSolo'); + } + break; + case 'Escape': + if (popupOpen) { + // Avoid Opera to exit fullscreen mode. + event.preventDefault(); + // Avoid the Modal to handle the event. + event.stopPropagation(); + handleClose(event, 'escape'); + } else if (clearOnEscape && (inputValue !== '' || (multiple && value.length > 0))) { + // Avoid Opera to exit fullscreen mode. + event.preventDefault(); + // Avoid the Modal to handle the event. + event.stopPropagation(); + handleClear(event); + } + break; + case 'Backspace': + // Remove the value on the left of the "cursor" + if (multiple && !readOnly && inputValue === '' && value.length > 0) { + const index = focusedTag === -1 ? value.length - 1 : focusedTag; + const newValue = value.slice(); + newValue.splice(index, 1); + handleValue(event, newValue, 'removeOption', { + option: value[index], + }); + } + break; + case 'Delete': + // Remove the value on the right of the "cursor" + if (multiple && !readOnly && inputValue === '' && value.length > 0 && focusedTag !== -1) { + const index = focusedTag; + const newValue = value.slice(); + newValue.splice(index, 1); + handleValue(event, newValue, 'removeOption', { + option: value[index], + }); + } + break; + default: + } + } + }; + + const handleFocus = (event) => { + setFocused(true); + + if (openOnFocus && !ignoreFocus.current) { + handleOpen(event); + } + }; + + const handleBlur = (event) => { + // Ignore the event when using the scrollbar with IE11 + if (unstable_isActiveElementInListbox(listboxRef)) { + inputRef.current.focus(); + return; + } + + setFocused(false); + firstFocus.current = true; + ignoreFocus.current = false; + + if (autoSelect && highlightedIndexRef.current !== -1 && popupOpen) { + selectNewValue(event, filteredOptions[highlightedIndexRef.current], 'blur'); + } else if (autoSelect && freeSolo && inputValue !== '') { + selectNewValue(event, inputValue, 'blur', 'freeSolo'); + } else if (clearOnBlur) { + resetInputValue(event, value); + } + + handleClose(event, 'blur'); + }; + + const handleInputChange = (event) => { + const newValue = event.target.value; + + if (inputValue !== newValue) { + setInputValueState(newValue); + setInputPristine(false); + + if (onInputChange) { + onInputChange(event, newValue, 'input'); + } + } + + if (newValue === '') { + if (!disableClearable && !multiple) { + handleValue(event, null, 'clear'); + } + } else { + handleOpen(event); + } + }; + + const handleOptionMouseMove = (event) => { + const index = Number(event.currentTarget.getAttribute('data-option-index')); + if (highlightedIndexRef.current !== index) { + setHighlightedIndex({ + event, + index, + reason: 'mouse', + }); + } + }; + + const handleOptionTouchStart = (event) => { + setHighlightedIndex({ + event, + index: Number(event.currentTarget.getAttribute('data-option-index')), + reason: 'touch', + }); + isTouch.current = true; + }; + + const handleOptionClick = (event) => { + const index = Number(event.currentTarget.getAttribute('data-option-index')); + selectNewValue(event, filteredOptions[index], 'selectOption'); + + isTouch.current = false; + }; + + const handleTagDelete = (index) => (event) => { + const newValue = value.slice(); + newValue.splice(index, 1); + handleValue(event, newValue, 'removeOption', { + option: value[index], + }); + }; + + const handlePopupIndicator = (event) => { + if (open) { + handleClose(event, 'toggleInput'); + } else { + handleOpen(event); + } + }; + + // 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)) { + return; + } + if (event.target.getAttribute('id') !== id) { + event.preventDefault(); + } + }; + + // 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)) { + return; + } + inputRef.current.focus(); + + if ( + selectOnFocus && + firstFocus.current && + inputRef.current.selectionEnd - inputRef.current.selectionStart === 0 + ) { + inputRef.current.select(); + } + + firstFocus.current = false; + }; + + const handleInputMouseDown = (event) => { + if (!disabledProp && (inputValue === '' || !open)) { + handlePopupIndicator(event); + } + }; + + let dirty = freeSolo && inputValue.length > 0; + dirty = dirty || (multiple ? value.length > 0 : value !== null); + + let groupedOptions = filteredOptions; + if (groupBy) { + // used to keep track of key and indexes in the result array + const indexBy = new Map(); + let warn = false; + + groupedOptions = filteredOptions.reduce((acc, option, index) => { + const group = groupBy(option); + + if (acc.length > 0 && acc[acc.length - 1].group === group) { + acc[acc.length - 1].options.push(option); + } else { + if (process.env.NODE_ENV !== 'production') { + if (indexBy.get(group) && !warn) { + console.warn( + `MUI: The options provided combined with the \`groupBy\` method of ${componentName} returns duplicated headers.`, + 'You can solve the issue by sorting the options with the output of `groupBy`.', + ); + warn = true; + } + indexBy.set(group, true); + } + + acc.push({ + key: index, + index, + group, + options: [option], + }); + } + + return acc; + }, []); + } + + if (disabledProp && focused) { + handleBlur(); + } + + return { + getRootProps: (other = {}) => ({ + 'aria-owns': listboxAvailable ? `${id}-listbox` : null, + ...other, + onKeyDown: handleKeyDown(other), + onMouseDown: handleMouseDown, + onClick: handleClick, + }), + getInputLabelProps: () => ({ + id: `${id}-label`, + htmlFor: id, + }), + getInputProps: () => ({ + id, + value: inputValue, + onBlur: handleBlur, + onFocus: handleFocus, + onChange: handleInputChange, + onMouseDown: handleInputMouseDown, + // if open then this is handled imperatively so don't let react override + // only have an opinion about this when closed + 'aria-activedescendant': popupOpen ? '' : null, + 'aria-autocomplete': autoComplete ? 'both' : 'list', + 'aria-controls': listboxAvailable ? `${id}-listbox` : undefined, + 'aria-expanded': listboxAvailable, + // Disable browser's suggestion that might overlap with the popup. + // Handle autocomplete but not autofill. + autoComplete: 'off', + ref: inputRef, + autoCapitalize: 'none', + spellCheck: 'false', + role: 'combobox', + disabled: disabledProp, + }), + getClearProps: () => ({ + tabIndex: -1, + type: 'button', + onClick: handleClear, + }), + getPopupIndicatorProps: () => ({ + tabIndex: -1, + type: 'button', + onClick: handlePopupIndicator, + }), + getTagProps: ({ index }) => ({ + key: index, + 'data-tag-index': index, + tabIndex: -1, + ...(!readOnly && { onDelete: handleTagDelete(index) }), + }), + getListboxProps: () => ({ + role: 'listbox', + id: `${id}-listbox`, + 'aria-labelledby': `${id}-label`, + ref: handleListboxRef, + onMouseDown: (event) => { + // Prevent blur + event.preventDefault(); + }, + }), + getOptionProps: ({ index, option }) => { + const selected = (multiple ? value : [value]).some( + (value2) => value2 != null && isOptionEqualToValue(option, value2), + ); + const disabled = getOptionDisabled ? getOptionDisabled(option) : false; + + return { + key: getOptionKey?.(option) ?? getOptionLabel(option), + tabIndex: -1, + role: 'option', + id: `${id}-option-${index}`, + onMouseMove: handleOptionMouseMove, + onClick: handleOptionClick, + onTouchStart: handleOptionTouchStart, + 'data-option-index': index, + 'aria-disabled': disabled, + 'aria-selected': selected, + }; + }, + id, + inputValue, + value, + dirty, + expanded: popupOpen && anchorEl, + popupOpen, + focused: focused || focusedTag !== -1, + anchorEl, + setAnchorEl, + focusedTag, + groupedOptions, + }; +} + +export default useAutocomplete; diff --git a/packages/mui-material/src/useAutocomplete/useAutocomplete.spec.ts b/packages/mui-material/src/useAutocomplete/useAutocomplete.spec.ts new file mode 100644 index 00000000000000..2d65f0f4d5a9e6 --- /dev/null +++ b/packages/mui-material/src/useAutocomplete/useAutocomplete.spec.ts @@ -0,0 +1,184 @@ +import { expectType } from '@mui/types'; +import { useAutocomplete, FilterOptionsState } from '@mui/material/useAutocomplete'; + +interface Person { + id: string; + name: string; +} + +const persons: Person[] = [ + { id: '1', name: 'Chris' }, + { id: '2', name: 'Kim' }, + { id: '3', name: 'Ben' }, + { id: '4', name: 'Matt' }, +]; + +function Component() { + // value type is inferred correctly when multiple is undefined + useAutocomplete({ + options: ['1', '2', '3'], + onChange(event, value) { + expectType(value); + }, + }); + + // value type is inferred correctly when multiple is false + useAutocomplete({ + options: ['1', '2', '3'], + multiple: false, + onChange(event, value) { + expectType(value); + }, + }); + + // value type is inferred correctly for type unions + useAutocomplete({ + options: ['1', '2', '3', 4, true], + onChange(event, value) { + expectType(value); + }, + }); + + // value type is inferred correctly for interface + useAutocomplete({ + options: persons, + onChange(event, value) { + expectType(value); + }, + }); + + // value type is inferred correctly when value is set + useAutocomplete({ + options: ['1', '2', '3'], + onChange(event, value) { + expectType(value); + value; + }, + filterOptions(options, state) { + expectType, typeof state>(state); + expectType(options); + return options; + }, + getOptionLabel(option) { + expectType(option); + return option; + }, + value: null, + }); + + // Multiple selection mode + + // value type is inferred correctly for simple type + useAutocomplete({ + options: ['1', '2', '3'], + multiple: true, + onChange(event, value) { + expectType(value); + value; + }, + }); + + // value type is inferred correctly for union type + useAutocomplete({ + options: ['1', '2', '3', 4, true], + multiple: true, + onChange(event, value) { + expectType, typeof value>(value); + }, + }); + + // value type is inferred correctly for interface + useAutocomplete({ + options: persons, + multiple: true, + onChange(event, value) { + expectType(value); + value; + }, + }); + + // no type inference conflict when value type is set explicitly + useAutocomplete({ + options: persons, + multiple: true, + onChange(event, value: Person[]) {}, + }); + + // options accepts const and value has correct type + useAutocomplete({ + options: ['1', '2', '3'] as const, + onChange(event, value) { + expectType<'1' | '2' | '3' | null, typeof value>(value); + }, + }); + + // Disable clearable + + useAutocomplete({ + options: ['1', '2', '3'], + disableClearable: true, + onChange(event, value) { + expectType(value); + }, + }); + + useAutocomplete({ + options: ['1', '2', '3'], + disableClearable: false, + onChange(event, value) { + expectType(value); + }, + }); + + useAutocomplete({ + options: ['1', '2', '3'], + onChange(event, value) { + expectType(value); + }, + }); + + // Free solo + useAutocomplete({ + options: persons, + onChange(event, value) { + expectType(value); + }, + freeSolo: true, + }); + + useAutocomplete({ + options: persons, + disableClearable: true, + onChange(event, value) { + expectType(value); + }, + freeSolo: true, + }); + + useAutocomplete({ + options: persons, + multiple: true, + onChange(event, value) { + expectType, typeof value>(value); + }, + freeSolo: true, + }); + + useAutocomplete({ + options: persons, + getOptionLabel(option) { + expectType(option); + return ''; + }, + freeSolo: true, + }); + + useAutocomplete({ + options: persons, + getOptionKey(option) { + expectType(option); + return ''; + }, + freeSolo: true, + }); +} diff --git a/packages/mui-material/src/useAutocomplete/useAutocomplete.test.js b/packages/mui-material/src/useAutocomplete/useAutocomplete.test.js new file mode 100644 index 00000000000000..83d1009d9dac9c --- /dev/null +++ b/packages/mui-material/src/useAutocomplete/useAutocomplete.test.js @@ -0,0 +1,380 @@ +import * as React from 'react'; +import { expect } from 'chai'; +import { createRenderer, screen, ErrorBoundary, act, fireEvent } from '@mui/internal-test-utils'; +import { spy } from 'sinon'; +import { useAutocomplete, createFilterOptions } from '@mui/material/useAutocomplete'; + +describe('useAutocomplete', () => { + const { render } = createRenderer(); + + it('should preserve DOM nodes of options when re-ordering', () => { + function Test(props) { + const { options } = props; + const { + groupedOptions, + getRootProps, + getInputLabelProps, + getInputProps, + getListboxProps, + getOptionProps, + } = useAutocomplete({ + options, + open: true, + }); + + return ( +
+
+ + +
+ {groupedOptions.length > 0 ? ( +
    + {groupedOptions.map((option, index) => { + const { key, ...optionProps } = getOptionProps({ option, index }); + return ( +
  • + {option} +
  • + ); + })} +
+ ) : null} +
+ ); + } + + const { rerender } = render(); + const [fooOptionAsFirst, barOptionAsSecond] = screen.getAllByRole('option'); + rerender(); + const [barOptionAsFirst, fooOptionAsSecond] = screen.getAllByRole('option'); + + // If the DOM nodes are not preserved VO will not read the first option again since it thinks it didn't change. + expect(fooOptionAsFirst).to.equal(fooOptionAsSecond); + expect(barOptionAsFirst).to.equal(barOptionAsSecond); + }); + + describe('createFilterOptions', () => { + it('defaults to getOptionLabel for text filtering', () => { + const filterOptions = createFilterOptions(); + + const getOptionLabel = (option) => option.name; + const options = [ + { + id: '1234', + name: 'cat', + }, + { + id: '5678', + name: 'dog', + }, + { + id: '9abc', + name: 'emu', + }, + ]; + + expect(filterOptions(options, { inputValue: 'a', getOptionLabel })).to.deep.equal([ + options[0], + ]); + }); + + it('filters without error with empty option set', () => { + const filterOptions = createFilterOptions(); + + const getOptionLabel = (option) => option.name; + const options = []; + + expect(filterOptions(options, { inputValue: 'a', getOptionLabel })).to.deep.equal([]); + }); + + describe('option: limit', () => { + it('limits the number of suggested options to be shown', () => { + const filterOptions = createFilterOptions({ limit: 2 }); + + const getOptionLabel = (option) => option.name; + const options = [ + { + id: '1234', + name: 'a1', + }, + { + id: '5678', + name: 'a2', + }, + { + id: '9abc', + name: 'a3', + }, + { + id: '9abc', + name: 'a4', + }, + ]; + + expect(filterOptions(options, { inputValue: 'a', getOptionLabel })).to.deep.equal([ + options[0], + options[1], + ]); + }); + }); + + describe('option: matchFrom', () => { + let filterOptions; + let getOptionLabel; + let options; + + beforeEach(() => { + filterOptions = createFilterOptions({ matchFrom: 'any' }); + getOptionLabel = (option) => option.name; + options = [ + { + id: '1234', + name: 'ab', + }, + { + id: '5678', + name: 'ba', + }, + { + id: '9abc', + name: 'ca', + }, + ]; + }); + + describe('any', () => { + it('show all results that match', () => { + expect(filterOptions(options, { inputValue: 'a', getOptionLabel })).to.deep.equal( + options, + ); + }); + }); + + describe('empty', () => { + it('does not call getOptionLabel if filter is empty', () => { + const getOptionLabelSpy = spy(getOptionLabel); + expect( + filterOptions(options, { inputValue: '', getOptionLabel: getOptionLabelSpy }), + ).to.deep.equal(options); + expect(getOptionLabelSpy.callCount).to.equal(0); + }); + }); + + describe('start', () => { + it('show only results that start with search', () => { + expect(filterOptions(options, { inputValue: 'a', getOptionLabel })).to.deep.equal( + options, + ); + }); + }); + }); + + describe('option: ignoreAccents', () => { + it('does not ignore accents', () => { + const filterOptions = createFilterOptions({ ignoreAccents: false }); + + const getOptionLabel = (option) => option.name; + const options = [ + { + id: '1234', + name: 'áb', + }, + { + id: '5678', + name: 'ab', + }, + { + id: '9abc', + name: 'áe', + }, + { + id: '9abc', + name: 'ae', + }, + ]; + + expect(filterOptions(options, { inputValue: 'á', getOptionLabel })).to.deep.equal([ + options[0], + options[2], + ]); + }); + }); + + describe('option: ignoreCase', () => { + it('matches results with case insensitive', () => { + const filterOptions = createFilterOptions({ ignoreCase: false }); + + const getOptionLabel = (option) => option.name; + const options = [ + { + id: '1234', + name: 'Ab', + }, + { + id: '5678', + name: 'ab', + }, + { + id: '9abc', + name: 'Ae', + }, + { + id: '9abc', + name: 'ae', + }, + ]; + + expect(filterOptions(options, { inputValue: 'A', getOptionLabel })).to.deep.equal([ + options[0], + options[2], + ]); + }); + }); + }); + + it('should warn if the input is not binded', function test() { + // TODO is this fixed? + if (!/jsdom/.test(window.navigator.userAgent)) { + // can't catch render errors in the browser for unknown reason + // tried try-catch + error boundary + window onError preventDefault + this.skip(); + } + + function Test(props) { + const { options } = props; + const { + groupedOptions, + getRootProps, + getInputLabelProps, + // getInputProps, + getListboxProps, + getOptionProps, + } = useAutocomplete({ + options, + open: true, + }); + + return ( +
+
+ +
+ {groupedOptions.length > 0 ? ( +
    + {groupedOptions.map((option, index) => { + const { key, ...optionProps } = getOptionProps({ option, index }); + return ( +
  • + {option} +
  • + ); + })} +
+ ) : null} +
+ ); + } + + const node16ErrorMessage = + "Error: Uncaught [TypeError: Cannot read properties of null (reading 'removeAttribute')]"; + const olderNodeErrorMessage = + "Error: Uncaught [TypeError: Cannot read property 'removeAttribute' of null]"; + + const nodeVersion = Number(process.versions.node.split('.')[0]); + const errorMessage = nodeVersion >= 16 ? node16ErrorMessage : olderNodeErrorMessage; + + const devErrorMessages = [ + errorMessage, + 'MUI: Unable to find the input element.', + errorMessage, + // strict effects runs effects twice + React.version.startsWith('18') && 'MUI: Unable to find the input element.', + React.version.startsWith('18') && errorMessage, + 'The above error occurred in the
    component', + React.version.startsWith('16') && 'The above error occurred in the
      component', + 'The above error occurred in the component', + // strict effects runs effects twice + React.version.startsWith('18') && 'The above error occurred in the component', + React.version.startsWith('16') && 'The above error occurred in the component', + ]; + + expect(() => { + render( + + + , + ); + }).toErrorDev(devErrorMessages); + }); + + describe('prop: freeSolo', () => { + it('should not reset if the component value does not change on blur', () => { + function Test(props) { + const { options } = props; + const { getInputProps } = useAutocomplete({ options, open: true, freeSolo: true }); + + return ; + } + render(); + const input = screen.getByRole('combobox'); + + act(() => { + fireEvent.change(input, { target: { value: 'free' } }); + input.blur(); + }); + + expect(input.value).to.equal('free'); + }); + }); + + describe('getInputProps', () => { + it('should disable input element', () => { + function Test(props) { + const { options } = props; + const { getInputProps } = useAutocomplete({ options, disabled: true }); + + return ; + } + render(); + const input = screen.getByRole('combobox'); + + expect(input).to.have.attribute('disabled'); + }); + }); + + it('should allow tuples or arrays as value when multiple=false', () => { + function Test() { + const defaultValue = ['bar']; + + const { getClearProps, getInputProps } = useAutocomplete({ + defaultValue, + disableClearable: false, + getOptionLabel: ([val]) => val, + isOptionEqualToValue: (option, value) => { + if (option === value) { + return true; + } + return option[0] === value[0]; + }, + multiple: false, + options: [['foo'], defaultValue, ['baz']], + }); + + return ( +
      + +
      + ); + } + + const { getByTestId } = render(); + + const button = getByTestId('button'); + + expect(() => { + fireEvent.click(button); + }).not.to.throw(); + }); +}); From dadb6ea6ec5148ae1514ea847702814097507702 Mon Sep 17 00:00:00 2001 From: mnajdova Date: Thu, 11 Jul 2024 10:40:22 +0200 Subject: [PATCH 04/24] Move NoSsr --- .../mui-material/src/NoSsr/NoSsr.test.tsx | 56 +++++++++++++++ packages/mui-material/src/NoSsr/NoSsr.tsx | 72 +++++++++++++++++++ .../mui-material/src/NoSsr/NoSsr.types.ts | 19 +++++ packages/mui-material/src/NoSsr/index.d.ts | 5 +- packages/mui-material/src/NoSsr/index.js | 2 +- .../src/SwipeableDrawer/SwipeableDrawer.js | 2 +- 6 files changed, 152 insertions(+), 4 deletions(-) create mode 100644 packages/mui-material/src/NoSsr/NoSsr.test.tsx create mode 100644 packages/mui-material/src/NoSsr/NoSsr.tsx create mode 100644 packages/mui-material/src/NoSsr/NoSsr.types.ts diff --git a/packages/mui-material/src/NoSsr/NoSsr.test.tsx b/packages/mui-material/src/NoSsr/NoSsr.test.tsx new file mode 100644 index 00000000000000..d085f84a54b946 --- /dev/null +++ b/packages/mui-material/src/NoSsr/NoSsr.test.tsx @@ -0,0 +1,56 @@ +import * as React from 'react'; +import { expect } from 'chai'; +import { createRenderer } from '@mui/internal-test-utils'; +import { NoSsr } from '@mui/material/NoSsr'; + +describe('', () => { + const { render, renderToString } = createRenderer(); + + describe('server-side rendering', () => { + it('should not render the children as the width is unknown', () => { + const { container } = renderToString( + + Hello + , + ); + + expect(container.firstChild).to.equal(null); + }); + }); + + describe('mounted', () => { + it('should render the children', () => { + render( + + + , + ); + expect(document.querySelector('#client-only')).not.to.equal(null); + }); + }); + + describe('prop: fallback', () => { + it('should render the fallback', () => { + const { container } = renderToString( +
      + + Hello + +
      , + ); + + expect(container.firstChild).to.have.text('fallback'); + }); + }); + + describe('prop: defer', () => { + it('should defer the rendering', () => { + render( + + Hello + , + ); + expect(document.querySelector('#client-only')).not.to.equal(null); + }); + }); +}); diff --git a/packages/mui-material/src/NoSsr/NoSsr.tsx b/packages/mui-material/src/NoSsr/NoSsr.tsx new file mode 100644 index 00000000000000..c637df94e70b61 --- /dev/null +++ b/packages/mui-material/src/NoSsr/NoSsr.tsx @@ -0,0 +1,72 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { exactProp, unstable_useEnhancedEffect as useEnhancedEffect } from '@mui/utils'; +import { NoSsrProps } from './NoSsr.types'; + +/** + * NoSsr purposely removes components from the subject of Server Side Rendering (SSR). + * + * This component can be useful in a variety of situations: + * + * * Escape hatch for broken dependencies not supporting SSR. + * * Improve the time-to-first paint on the client by only rendering above the fold. + * * Reduce the rendering time on the server. + * * Under too heavy server load, you can turn on service degradation. + * + * Demos: + * + * - [No SSR](https://mui.com/base-ui/react-no-ssr/) + * + * API: + * + * - [NoSsr API](https://mui.com/base-ui/react-no-ssr/components-api/#no-ssr) + */ +function NoSsr(props: NoSsrProps): React.JSX.Element { + const { children, defer = false, fallback = null } = props; + const [mountedState, setMountedState] = React.useState(false); + + useEnhancedEffect(() => { + if (!defer) { + setMountedState(true); + } + }, [defer]); + + React.useEffect(() => { + if (defer) { + setMountedState(true); + } + }, [defer]); + + // We need the Fragment here to force react-docgen to recognise NoSsr as a component. + return {mountedState ? children : fallback}; +} + +NoSsr.propTypes /* remove-proptypes */ = { + // ┌────────────────────────────── Warning ──────────────────────────────┐ + // │ These PropTypes are generated from the TypeScript type definitions. │ + // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ + // └─────────────────────────────────────────────────────────────────────┘ + /** + * You can wrap a node. + */ + children: PropTypes.node, + /** + * If `true`, the component will not only prevent server-side rendering. + * It will also defer the rendering of the children into a different screen frame. + * @default false + */ + defer: PropTypes.bool, + /** + * The fallback content to display. + * @default null + */ + fallback: PropTypes.node, +} as any; + +if (process.env.NODE_ENV !== 'production') { + // eslint-disable-next-line + (NoSsr as any)['propTypes' + ''] = exactProp(NoSsr.propTypes); +} + +export { NoSsr }; diff --git a/packages/mui-material/src/NoSsr/NoSsr.types.ts b/packages/mui-material/src/NoSsr/NoSsr.types.ts new file mode 100644 index 00000000000000..1efd5c8771a875 --- /dev/null +++ b/packages/mui-material/src/NoSsr/NoSsr.types.ts @@ -0,0 +1,19 @@ +import * as React from 'react'; + +export interface NoSsrProps { + /** + * You can wrap a node. + */ + children?: React.ReactNode; + /** + * If `true`, the component will not only prevent server-side rendering. + * It will also defer the rendering of the children into a different screen frame. + * @default false + */ + defer?: boolean; + /** + * The fallback content to display. + * @default null + */ + fallback?: React.ReactNode; +} diff --git a/packages/mui-material/src/NoSsr/index.d.ts b/packages/mui-material/src/NoSsr/index.d.ts index 46f3679c99a21b..00eb3ce5c5f65f 100644 --- a/packages/mui-material/src/NoSsr/index.d.ts +++ b/packages/mui-material/src/NoSsr/index.d.ts @@ -1,2 +1,3 @@ -export { NoSsr as default } from '@mui/base/NoSsr'; -export * from '@mui/base/NoSsr'; +export { NoSsr as default } from './NoSsr'; +export * from './NoSsr'; +export * from './NoSsr.types'; diff --git a/packages/mui-material/src/NoSsr/index.js b/packages/mui-material/src/NoSsr/index.js index 4f858cd5ad29f6..d366f03eff00f4 100644 --- a/packages/mui-material/src/NoSsr/index.js +++ b/packages/mui-material/src/NoSsr/index.js @@ -1 +1 @@ -export { NoSsr as default } from '@mui/base/NoSsr'; +export { NoSsr as default } from './NoSsr'; diff --git a/packages/mui-material/src/SwipeableDrawer/SwipeableDrawer.js b/packages/mui-material/src/SwipeableDrawer/SwipeableDrawer.js index 35a2a03ebb8f14..02b9e9354e13d1 100644 --- a/packages/mui-material/src/SwipeableDrawer/SwipeableDrawer.js +++ b/packages/mui-material/src/SwipeableDrawer/SwipeableDrawer.js @@ -3,7 +3,7 @@ import * as React from 'react'; import * as ReactDOM from 'react-dom'; import PropTypes from 'prop-types'; import elementTypeAcceptingRef from '@mui/utils/elementTypeAcceptingRef'; -import { NoSsr } from '@mui/base'; +import NoSsr from '../NoSsr'; import Drawer, { getAnchor, isHorizontal } from '../Drawer/Drawer'; import useForkRef from '../utils/useForkRef'; import ownerDocument from '../utils/ownerDocument'; From bdb360173de1a9a4494887494bef5a5558561778 Mon Sep 17 00:00:00 2001 From: mnajdova Date: Thu, 11 Jul 2024 10:45:06 +0200 Subject: [PATCH 05/24] Move Slider and ClassNameGenerator --- packages/mui-material/src/Slider/Slider.d.ts | 2 +- packages/mui-material/src/Slider/Slider.js | 2 +- .../mui-material/src/Slider/Slider.test.js | 3 +- .../mui-material/src/Slider/useSlider.test.js | 91 +++ packages/mui-material/src/Slider/useSlider.ts | 747 ++++++++++++++++++ .../src/Slider/useSlider.types.ts | 259 ++++++ packages/mui-material/src/utils/index.d.ts | 2 +- packages/mui-material/src/utils/index.js | 2 +- 8 files changed, 1102 insertions(+), 6 deletions(-) create mode 100644 packages/mui-material/src/Slider/useSlider.test.js create mode 100644 packages/mui-material/src/Slider/useSlider.ts create mode 100644 packages/mui-material/src/Slider/useSlider.types.ts diff --git a/packages/mui-material/src/Slider/Slider.d.ts b/packages/mui-material/src/Slider/Slider.d.ts index 9f4a130528322c..5956cd492660b3 100644 --- a/packages/mui-material/src/Slider/Slider.d.ts +++ b/packages/mui-material/src/Slider/Slider.d.ts @@ -1,7 +1,7 @@ import * as React from 'react'; -import { Mark } from '@mui/base/useSlider'; import { SxProps } from '@mui/system'; import { OverridableStringUnion } from '@mui/types'; +import { Mark } from './useSlider.types'; import { SlotComponentProps } from '../utils/types'; import { Theme } from '../styles'; import { OverrideProps, OverridableComponent } from '../OverridableComponent'; diff --git a/packages/mui-material/src/Slider/Slider.js b/packages/mui-material/src/Slider/Slider.js index b70430506121d6..191823d7371529 100644 --- a/packages/mui-material/src/Slider/Slider.js +++ b/packages/mui-material/src/Slider/Slider.js @@ -4,9 +4,9 @@ import PropTypes from 'prop-types'; import clsx from 'clsx'; import chainPropTypes from '@mui/utils/chainPropTypes'; import composeClasses from '@mui/utils/composeClasses'; -import { useSlider, valueToPercent } from '@mui/base/useSlider'; import { alpha, lighten, darken } from '@mui/system/colorManipulator'; import { useRtl } from '@mui/system/RtlProvider'; +import { useSlider, valueToPercent } from './useSlider'; import { useSlotProps } from '../utils/useSlotProps'; import { isHostComponent } from '../utils/isHostComponent'; import { styled } from '../zero-styled'; diff --git a/packages/mui-material/src/Slider/Slider.test.js b/packages/mui-material/src/Slider/Slider.test.js index 9dde053cf3708b..07e62bf1bd03a6 100644 --- a/packages/mui-material/src/Slider/Slider.test.js +++ b/packages/mui-material/src/Slider/Slider.test.js @@ -3,7 +3,6 @@ import PropTypes from 'prop-types'; import { spy, stub } from 'sinon'; import { expect } from 'chai'; import { act, createRenderer, fireEvent, screen } from '@mui/internal-test-utils'; -import { Slider as BaseSlider } from '@mui/base/Slider'; import { ThemeProvider, createTheme } from '@mui/material/styles'; import Slider, { sliderClasses as classes } from '@mui/material/Slider'; import describeConformance from '../../test/describeConformance'; @@ -1265,7 +1264,7 @@ describe('', () => { }); it('should remove the slider from the tab sequence', () => { - render(); + render(); expect(screen.getByRole('slider')).to.have.property('tabIndex', -1); }); diff --git a/packages/mui-material/src/Slider/useSlider.test.js b/packages/mui-material/src/Slider/useSlider.test.js new file mode 100644 index 00000000000000..e4ee0313bda6a5 --- /dev/null +++ b/packages/mui-material/src/Slider/useSlider.test.js @@ -0,0 +1,91 @@ +import * as React from 'react'; +import { expect } from 'chai'; +import { spy } from 'sinon'; +import { createRenderer, screen, fireEvent } from '@mui/internal-test-utils'; +import { useSlider } from './useSlider'; + +describe('useSlider', () => { + const { render } = createRenderer(); + + describe('getRootProps', () => { + it('forwards external props including event handlers', () => { + const rootRef = React.createRef(); + + const handleClick = spy(); + + function Test() { + const { getRootProps } = useSlider({ + rootRef, + marks: [ + { + label: 'One', + value: 1, + }, + ], + }); + + return ( +
      + ); + } + + render(); + + const slider = screen.getByTestId('test-slider-root'); + expect(slider).not.to.equal(null); + expect(rootRef.current).to.deep.equal(slider); + + fireEvent.click(slider); + expect(handleClick.callCount).to.equal(1); + }); + }); + + describe('getHiddenInputProps', () => { + function Test( + props = { + slotProps: { + input: {}, + }, + }, + ) { + const { getRootProps, getThumbProps, getHiddenInputProps } = useSlider({ + marks: [ + { + label: 'One', + value: 1, + }, + ], + }); + + return ( +
      +
      + +
      +
      + ); + } + + it('forwards external props including event handlers', () => { + const handleClick = spy(); + render( + , + ); + + const input = screen.getByTestId('test-input'); + expect(input).not.to.equal(null); + + fireEvent.click(input); + expect(handleClick.callCount).to.equal(1); + }); + }); +}); diff --git a/packages/mui-material/src/Slider/useSlider.ts b/packages/mui-material/src/Slider/useSlider.ts new file mode 100644 index 00000000000000..37dbea1d3fbec0 --- /dev/null +++ b/packages/mui-material/src/Slider/useSlider.ts @@ -0,0 +1,747 @@ +'use client'; +import * as React from 'react'; +import { + unstable_ownerDocument as ownerDocument, + unstable_useControlled as useControlled, + unstable_useEnhancedEffect as useEnhancedEffect, + unstable_useEventCallback as useEventCallback, + unstable_useForkRef as useForkRef, + unstable_isFocusVisible as isFocusVisible, + visuallyHidden, + clamp, +} from '@mui/utils'; +import { + Mark, + UseSliderHiddenInputProps, + UseSliderParameters, + UseSliderReturnValue, + UseSliderRootSlotProps, + UseSliderThumbSlotProps, +} from './useSlider.types'; +import { areArraysEqual, EventHandlers, extractEventHandlers } from '../utils'; + +const INTENTIONAL_DRAG_COUNT_THRESHOLD = 2; + +function asc(a: number, b: number) { + return a - b; +} + +function findClosest(values: number[], currentValue: number) { + const { index: closestIndex } = + values.reduce<{ distance: number; index: number } | null>( + (acc, value: number, index: number) => { + const distance = Math.abs(currentValue - value); + + if (acc === null || distance < acc.distance || distance === acc.distance) { + return { + distance, + index, + }; + } + + return acc; + }, + null, + ) ?? {}; + return closestIndex; +} + +function trackFinger( + event: TouchEvent | MouseEvent | React.MouseEvent, + touchId: React.RefObject, +) { + // The event is TouchEvent + if (touchId.current !== undefined && (event as TouchEvent).changedTouches) { + const touchEvent = event as TouchEvent; + for (let i = 0; i < touchEvent.changedTouches.length; i += 1) { + const touch = touchEvent.changedTouches[i]; + if (touch.identifier === touchId.current) { + return { + x: touch.clientX, + y: touch.clientY, + }; + } + } + + return false; + } + + // The event is MouseEvent + return { + x: (event as MouseEvent).clientX, + y: (event as MouseEvent).clientY, + }; +} + +export function valueToPercent(value: number, min: number, max: number) { + return ((value - min) * 100) / (max - min); +} + +function percentToValue(percent: number, min: number, max: number) { + return (max - min) * percent + min; +} + +function getDecimalPrecision(num: number) { + // This handles the case when num is very small (0.00000001), js will turn this into 1e-8. + // When num is bigger than 1 or less than -1 it won't get converted to this notation so it's fine. + if (Math.abs(num) < 1) { + const parts = num.toExponential().split('e-'); + const matissaDecimalPart = parts[0].split('.')[1]; + return (matissaDecimalPart ? matissaDecimalPart.length : 0) + parseInt(parts[1], 10); + } + + const decimalPart = num.toString().split('.')[1]; + return decimalPart ? decimalPart.length : 0; +} + +function roundValueToStep(value: number, step: number, min: number) { + const nearest = Math.round((value - min) / step) * step + min; + return Number(nearest.toFixed(getDecimalPrecision(step))); +} + +function setValueIndex({ + values, + newValue, + index, +}: { + values: number[]; + newValue: number; + index: number; +}) { + const output = values.slice(); + output[index] = newValue; + return output.sort(asc); +} + +function focusThumb({ + sliderRef, + activeIndex, + setActive, +}: { + sliderRef: React.RefObject; + activeIndex: number; + setActive?: (num: number) => void; +}) { + const doc = ownerDocument(sliderRef.current); + if ( + !sliderRef.current?.contains(doc.activeElement) || + Number(doc?.activeElement?.getAttribute('data-index')) !== activeIndex + ) { + sliderRef.current?.querySelector(`[type="range"][data-index="${activeIndex}"]`).focus(); + } + + if (setActive) { + setActive(activeIndex); + } +} + +function areValuesEqual( + newValue: number | ReadonlyArray, + oldValue: number | ReadonlyArray, +): boolean { + if (typeof newValue === 'number' && typeof oldValue === 'number') { + return newValue === oldValue; + } + if (typeof newValue === 'object' && typeof oldValue === 'object') { + return areArraysEqual(newValue, oldValue); + } + return false; +} + +const axisProps = { + horizontal: { + offset: (percent: number) => ({ left: `${percent}%` }), + leap: (percent: number) => ({ width: `${percent}%` }), + }, + 'horizontal-reverse': { + offset: (percent: number) => ({ right: `${percent}%` }), + leap: (percent: number) => ({ width: `${percent}%` }), + }, + vertical: { + offset: (percent: number) => ({ bottom: `${percent}%` }), + leap: (percent: number) => ({ height: `${percent}%` }), + }, +}; + +export const Identity = (x: any) => x; + +// TODO: remove support for Safari < 13. +// https://caniuse.com/#search=touch-action +// +// Safari, on iOS, supports touch action since v13. +// Over 80% of the iOS phones are compatible +// in August 2020. +// Utilizing the CSS.supports method to check if touch-action is supported. +// Since CSS.supports is supported on all but Edge@12 and IE and touch-action +// is supported on both Edge@12 and IE if CSS.supports is not available that means that +// touch-action will be supported +let cachedSupportsTouchActionNone: any; +function doesSupportTouchActionNone() { + if (cachedSupportsTouchActionNone === undefined) { + if (typeof CSS !== 'undefined' && typeof CSS.supports === 'function') { + cachedSupportsTouchActionNone = CSS.supports('touch-action', 'none'); + } else { + cachedSupportsTouchActionNone = true; + } + } + return cachedSupportsTouchActionNone; +} +/** + * + * Demos: + * + * - [Slider](https://next.mui.com/base-ui/react-slider/#hook) + * + * API: + * + * - [useSlider API](https://next.mui.com/base-ui/react-slider/hooks-api/#use-slider) + */ +export function useSlider(parameters: UseSliderParameters): UseSliderReturnValue { + const { + 'aria-labelledby': ariaLabelledby, + defaultValue, + disabled = false, + disableSwap = false, + isRtl = false, + marks: marksProp = false, + max = 100, + min = 0, + name, + onChange, + onChangeCommitted, + orientation = 'horizontal', + rootRef: ref, + scale = Identity, + step = 1, + shiftStep = 10, + tabIndex, + value: valueProp, + } = parameters; + + const touchId = React.useRef(); + // We can't use the :active browser pseudo-classes. + // - The active state isn't triggered when clicking on the rail. + // - The active state isn't transferred when inversing a range slider. + const [active, setActive] = React.useState(-1); + const [open, setOpen] = React.useState(-1); + const [dragging, setDragging] = React.useState(false); + const moveCount = React.useRef(0); + + const [valueDerived, setValueState] = useControlled({ + controlled: valueProp, + default: defaultValue ?? min, + name: 'Slider', + }); + + const handleChange = + onChange && + ((event: Event | React.SyntheticEvent, value: number | number[], thumbIndex: number) => { + // Redefine target to allow name and value to be read. + // This allows seamless integration with the most popular form libraries. + // https://github.com/mui/material-ui/issues/13485#issuecomment-676048492 + // Clone the event to not override `target` of the original event. + const nativeEvent = (event as React.SyntheticEvent).nativeEvent || event; + // @ts-ignore The nativeEvent is function, not object + const clonedEvent = new nativeEvent.constructor(nativeEvent.type, nativeEvent); + + Object.defineProperty(clonedEvent, 'target', { + writable: true, + value: { value, name }, + }); + + onChange(clonedEvent, value, thumbIndex); + }); + + const range = Array.isArray(valueDerived); + let values = range ? valueDerived.slice().sort(asc) : [valueDerived]; + values = values.map((value) => (value == null ? min : clamp(value, min, max))); + + const marks = + marksProp === true && step !== null + ? [...Array(Math.floor((max - min) / step) + 1)].map((_, index) => ({ + value: min + step * index, + })) + : marksProp || []; + + const marksValues = (marks as Mark[]).map((mark: Mark) => mark.value); + + const [focusedThumbIndex, setFocusedThumbIndex] = React.useState(-1); + + const sliderRef = React.useRef(); + const handleRef = useForkRef(ref, sliderRef); + + const createHandleHiddenInputFocus = + (otherHandlers: EventHandlers) => (event: React.FocusEvent) => { + const index = Number(event.currentTarget.getAttribute('data-index')); + if (isFocusVisible(event.target)) { + setFocusedThumbIndex(index); + } + setOpen(index); + otherHandlers?.onFocus?.(event); + }; + const createHandleHiddenInputBlur = + (otherHandlers: EventHandlers) => (event: React.FocusEvent) => { + if (!isFocusVisible(event.target)) { + setFocusedThumbIndex(-1); + } + setOpen(-1); + otherHandlers?.onBlur?.(event); + }; + + const changeValue = (event: React.KeyboardEvent | React.ChangeEvent, valueInput: number) => { + const index = Number(event.currentTarget.getAttribute('data-index')); + const value = values[index]; + const marksIndex = marksValues.indexOf(value); + let newValue: number | number[] = valueInput; + + if (marks && step == null) { + const maxMarksValue = marksValues[marksValues.length - 1]; + if (newValue > maxMarksValue) { + newValue = maxMarksValue; + } else if (newValue < marksValues[0]) { + newValue = marksValues[0]; + } else { + newValue = newValue < value ? marksValues[marksIndex - 1] : marksValues[marksIndex + 1]; + } + } + + newValue = clamp(newValue, min, max); + + if (range) { + // Bound the new value to the thumb's neighbours. + if (disableSwap) { + newValue = clamp(newValue, values[index - 1] || -Infinity, values[index + 1] || Infinity); + } + + const previousValue = newValue; + newValue = setValueIndex({ + values, + newValue, + index, + }); + + let activeIndex = index; + + // Potentially swap the index if needed. + if (!disableSwap) { + activeIndex = newValue.indexOf(previousValue); + } + + focusThumb({ sliderRef, activeIndex }); + } + + setValueState(newValue); + setFocusedThumbIndex(index); + + if (handleChange && !areValuesEqual(newValue, valueDerived)) { + handleChange(event, newValue, index); + } + + if (onChangeCommitted) { + onChangeCommitted(event, newValue); + } + }; + + const createHandleHiddenInputKeyDown = + (otherHandlers: EventHandlers) => (event: React.KeyboardEvent) => { + // The Shift + Up/Down keyboard shortcuts for moving the slider makes sense to be supported + // only if the step is defined. If the step is null, this means tha the marks are used for specifying the valid values. + if (step !== null) { + const index = Number(event.currentTarget.getAttribute('data-index')); + const value = values[index]; + + let newValue = null; + if ( + ((event.key === 'ArrowLeft' || event.key === 'ArrowDown') && event.shiftKey) || + event.key === 'PageDown' + ) { + newValue = Math.max(value - shiftStep, min); + } else if ( + ((event.key === 'ArrowRight' || event.key === 'ArrowUp') && event.shiftKey) || + event.key === 'PageUp' + ) { + newValue = Math.min(value + shiftStep, max); + } + + if (newValue !== null) { + changeValue(event, newValue); + event.preventDefault(); + } + } + + otherHandlers?.onKeyDown?.(event); + }; + + useEnhancedEffect(() => { + if (disabled && sliderRef.current!.contains(document.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 + // @ts-ignore + document.activeElement?.blur(); + } + }, [disabled]); + + if (disabled && active !== -1) { + setActive(-1); + } + if (disabled && focusedThumbIndex !== -1) { + setFocusedThumbIndex(-1); + } + + const createHandleHiddenInputChange = + (otherHandlers: EventHandlers) => (event: React.ChangeEvent) => { + otherHandlers.onChange?.(event); + // @ts-ignore + changeValue(event, event.target.valueAsNumber); + }; + + const previousIndex = React.useRef(); + let axis = orientation; + if (isRtl && orientation === 'horizontal') { + axis += '-reverse'; + } + + const getFingerNewValue = ({ + finger, + move = false, + }: { + finger: { x: number; y: number }; + move?: boolean; + }) => { + const { current: slider } = sliderRef; + const { width, height, bottom, left } = slider!.getBoundingClientRect(); + let percent; + + if (axis.indexOf('vertical') === 0) { + percent = (bottom - finger.y) / height; + } else { + percent = (finger.x - left) / width; + } + + if (axis.indexOf('-reverse') !== -1) { + percent = 1 - percent; + } + + let newValue; + newValue = percentToValue(percent, min, max); + if (step) { + newValue = roundValueToStep(newValue, step, min); + } else { + const closestIndex = findClosest(marksValues, newValue); + newValue = marksValues[closestIndex!]; + } + + newValue = clamp(newValue, min, max); + let activeIndex = 0; + + if (range) { + if (!move) { + activeIndex = findClosest(values, newValue)!; + } else { + activeIndex = previousIndex.current!; + } + + // Bound the new value to the thumb's neighbours. + if (disableSwap) { + newValue = clamp( + newValue, + values[activeIndex - 1] || -Infinity, + values[activeIndex + 1] || Infinity, + ); + } + + const previousValue = newValue; + newValue = setValueIndex({ + values, + newValue, + index: activeIndex, + }); + + // Potentially swap the index if needed. + if (!(disableSwap && move)) { + activeIndex = newValue.indexOf(previousValue); + previousIndex.current = activeIndex; + } + } + + return { newValue, activeIndex }; + }; + + const handleTouchMove = useEventCallback((nativeEvent: TouchEvent | MouseEvent) => { + const finger = trackFinger(nativeEvent, touchId); + + if (!finger) { + return; + } + + moveCount.current += 1; + + // Cancel move in case some other element consumed a mouseup event and it was not fired. + // @ts-ignore buttons doesn't not exists on touch event + if (nativeEvent.type === 'mousemove' && nativeEvent.buttons === 0) { + // eslint-disable-next-line @typescript-eslint/no-use-before-define + handleTouchEnd(nativeEvent); + return; + } + + const { newValue, activeIndex } = getFingerNewValue({ + finger, + move: true, + }); + + focusThumb({ sliderRef, activeIndex, setActive }); + setValueState(newValue); + + if (!dragging && moveCount.current > INTENTIONAL_DRAG_COUNT_THRESHOLD) { + setDragging(true); + } + + if (handleChange && !areValuesEqual(newValue, valueDerived)) { + handleChange(nativeEvent, newValue, activeIndex); + } + }); + + const handleTouchEnd = useEventCallback((nativeEvent: TouchEvent | MouseEvent) => { + const finger = trackFinger(nativeEvent, touchId); + setDragging(false); + + if (!finger) { + return; + } + + const { newValue } = getFingerNewValue({ finger, move: true }); + + setActive(-1); + if (nativeEvent.type === 'touchend') { + setOpen(-1); + } + + if (onChangeCommitted) { + onChangeCommitted(nativeEvent, newValue); + } + + touchId.current = undefined; + + // eslint-disable-next-line @typescript-eslint/no-use-before-define + stopListening(); + }); + + const handleTouchStart = useEventCallback((nativeEvent: TouchEvent) => { + if (disabled) { + return; + } + // If touch-action: none; is not supported we need to prevent the scroll manually. + if (!doesSupportTouchActionNone()) { + nativeEvent.preventDefault(); + } + + const touch = nativeEvent.changedTouches[0]; + if (touch != null) { + // A number that uniquely identifies the current finger in the touch session. + touchId.current = touch.identifier; + } + const finger = trackFinger(nativeEvent, touchId); + if (finger !== false) { + const { newValue, activeIndex } = getFingerNewValue({ finger }); + focusThumb({ sliderRef, activeIndex, setActive }); + + setValueState(newValue); + + if (handleChange && !areValuesEqual(newValue, valueDerived)) { + handleChange(nativeEvent, newValue, activeIndex); + } + } + + moveCount.current = 0; + const doc = ownerDocument(sliderRef.current); + doc.addEventListener('touchmove', handleTouchMove, { passive: true }); + doc.addEventListener('touchend', handleTouchEnd, { passive: true }); + }); + + const stopListening = React.useCallback(() => { + const doc = ownerDocument(sliderRef.current); + doc.removeEventListener('mousemove', handleTouchMove); + doc.removeEventListener('mouseup', handleTouchEnd); + doc.removeEventListener('touchmove', handleTouchMove); + doc.removeEventListener('touchend', handleTouchEnd); + }, [handleTouchEnd, handleTouchMove]); + + React.useEffect(() => { + const { current: slider } = sliderRef; + slider!.addEventListener('touchstart', handleTouchStart, { + passive: doesSupportTouchActionNone(), + }); + + return () => { + slider!.removeEventListener('touchstart', handleTouchStart); + + stopListening(); + }; + }, [stopListening, handleTouchStart]); + + React.useEffect(() => { + if (disabled) { + stopListening(); + } + }, [disabled, stopListening]); + + const createHandleMouseDown = + (otherHandlers: EventHandlers) => (event: React.MouseEvent) => { + otherHandlers.onMouseDown?.(event); + if (disabled) { + return; + } + + if (event.defaultPrevented) { + return; + } + + // Only handle left clicks + if (event.button !== 0) { + return; + } + + // Avoid text selection + event.preventDefault(); + const finger = trackFinger(event, touchId); + if (finger !== false) { + const { newValue, activeIndex } = getFingerNewValue({ finger }); + focusThumb({ sliderRef, activeIndex, setActive }); + + setValueState(newValue); + + if (handleChange && !areValuesEqual(newValue, valueDerived)) { + handleChange(event, newValue, activeIndex); + } + } + + moveCount.current = 0; + const doc = ownerDocument(sliderRef.current); + doc.addEventListener('mousemove', handleTouchMove, { passive: true }); + doc.addEventListener('mouseup', handleTouchEnd); + }; + + const trackOffset = valueToPercent(range ? values[0] : min, min, max); + const trackLeap = valueToPercent(values[values.length - 1], min, max) - trackOffset; + + const getRootProps = = {}>( + externalProps: ExternalProps = {} as ExternalProps, + ): UseSliderRootSlotProps => { + const externalHandlers = extractEventHandlers(externalProps); + + const ownEventHandlers = { + onMouseDown: createHandleMouseDown(externalHandlers || {}), + }; + + const mergedEventHandlers = { + ...externalHandlers, + ...ownEventHandlers, + }; + + return { + ...externalProps, + ref: handleRef, + ...mergedEventHandlers, + }; + }; + + const createHandleMouseOver = + (otherHandlers: EventHandlers) => (event: React.MouseEvent) => { + otherHandlers.onMouseOver?.(event); + + const index = Number(event.currentTarget.getAttribute('data-index')); + setOpen(index); + }; + + const createHandleMouseLeave = + (otherHandlers: EventHandlers) => (event: React.MouseEvent) => { + otherHandlers.onMouseLeave?.(event); + + setOpen(-1); + }; + + const getThumbProps = = {}>( + externalProps: ExternalProps = {} as ExternalProps, + ): UseSliderThumbSlotProps => { + const externalHandlers = extractEventHandlers(externalProps); + + const ownEventHandlers = { + onMouseOver: createHandleMouseOver(externalHandlers || {}), + onMouseLeave: createHandleMouseLeave(externalHandlers || {}), + }; + + return { + ...externalProps, + ...externalHandlers, + ...ownEventHandlers, + }; + }; + + const getThumbStyle = (index: number) => { + return { + // So the non active thumb doesn't show its label on hover. + pointerEvents: active !== -1 && active !== index ? 'none' : undefined, + }; + }; + + const getHiddenInputProps = = {}>( + externalProps: ExternalProps = {} as ExternalProps, + ): UseSliderHiddenInputProps => { + const externalHandlers = extractEventHandlers(externalProps); + + const ownEventHandlers = { + onChange: createHandleHiddenInputChange(externalHandlers || {}), + onFocus: createHandleHiddenInputFocus(externalHandlers || {}), + onBlur: createHandleHiddenInputBlur(externalHandlers || {}), + onKeyDown: createHandleHiddenInputKeyDown(externalHandlers || {}), + }; + + const mergedEventHandlers = { + ...externalHandlers, + ...ownEventHandlers, + }; + + return { + tabIndex, + 'aria-labelledby': ariaLabelledby, + 'aria-orientation': orientation, + 'aria-valuemax': scale(max), + 'aria-valuemin': scale(min), + name, + type: 'range', + min: parameters.min, + max: parameters.max, + step: parameters.step === null && parameters.marks ? 'any' : parameters.step ?? undefined, + disabled, + ...externalProps, + ...mergedEventHandlers, + style: { + ...visuallyHidden, + direction: isRtl ? 'rtl' : 'ltr', + // So that VoiceOver's focus indicator matches the thumb's dimensions + width: '100%', + height: '100%', + }, + }; + }; + + return { + active, + axis: axis as keyof typeof axisProps, + axisProps, + dragging, + focusedThumbIndex, + getHiddenInputProps, + getRootProps, + getThumbProps, + marks: marks as Mark[], + open, + range, + rootRef: handleRef, + trackLeap, + trackOffset, + values, + getThumbStyle, + }; +} diff --git a/packages/mui-material/src/Slider/useSlider.types.ts b/packages/mui-material/src/Slider/useSlider.types.ts new file mode 100644 index 00000000000000..38f411b5df6335 --- /dev/null +++ b/packages/mui-material/src/Slider/useSlider.types.ts @@ -0,0 +1,259 @@ +import * as React from 'react'; + +export interface UseSliderParameters { + /** + * The id of the element containing a label for the slider. + */ + 'aria-labelledby'?: string; + /** + * The default value. Use when the component is not controlled. + */ + defaultValue?: number | ReadonlyArray; + /** + * If `true`, the component is disabled. + * @default false + */ + disabled?: boolean; + /** + * If `true`, the active thumb doesn't swap when moving pointer over a thumb while dragging another thumb. + * @default false + */ + disableSwap?: boolean; + /** + * If `true` the Slider will be rendered right-to-left (with the lowest value on the right-hand side). + * @default false + */ + isRtl?: boolean; + /** + * Marks indicate predetermined values to which the user can move the slider. + * If `true` the marks are spaced according the value of the `step` prop. + * If an array, it should contain objects with `value` and an optional `label` keys. + * @default false + */ + marks?: boolean | ReadonlyArray; + /** + * The maximum allowed value of the slider. + * Should not be equal to min. + * @default 100 + */ + max?: number; + /** + * The minimum allowed value of the slider. + * Should not be equal to max. + * @default 0 + */ + min?: number; + /** + * Name attribute of the hidden `input` element. + */ + name?: string; + /** + * Callback function that is fired when the slider's value changed. + * + * @param {Event} event The event source of the callback. + * You can pull out the new value by accessing `event.target.value` (any). + * **Warning**: This is a generic event not a change event. + * @param {number | number[]} value The new value. + * @param {number} activeThumb Index of the currently moved thumb. + */ + onChange?: (event: Event, value: number | number[], activeThumb: number) => void; + /** + * Callback function that is fired when the `mouseup` is triggered. + * + * @param {React.SyntheticEvent | Event} event The event source of the callback. **Warning**: This is a generic event not a change event. + * @param {number | number[]} value The new value. + */ + onChangeCommitted?: (event: React.SyntheticEvent | Event, value: number | number[]) => void; + /** + * The component orientation. + * @default 'horizontal' + */ + orientation?: 'horizontal' | 'vertical'; + /** + * The ref attached to the root of the Slider. + */ + rootRef?: React.Ref; + /** + * A transformation function, to change the scale of the slider. + * @param {any} x + * @returns {any} + * @default function Identity(x) { + * return x; + * } + */ + scale?: (value: number) => number; + /** + * The granularity with which the slider can step through values when using Page Up/Page Down or Shift + Arrow Up/Arrow Down. + * @default 10 + */ + shiftStep?: number; + /** + * The granularity with which the slider can step through values. (A "discrete" slider.) + * The `min` prop serves as the origin for the valid values. + * We recommend (max - min) to be evenly divisible by the step. + * + * When step is `null`, the thumb can only be slid onto marks provided with the `marks` prop. + * @default 1 + */ + step?: number | null; + /** + * Tab index attribute of the hidden `input` element. + */ + tabIndex?: number; + /** + * The value of the slider. + * For ranged sliders, provide an array with two values. + */ + value?: number | ReadonlyArray; +} + +export interface Mark { + value: number; + label?: React.ReactNode; +} + +export type UseSliderRootSlotOwnProps = { + onMouseDown: React.MouseEventHandler; + ref: React.RefCallback | null; +}; + +export type UseSliderRootSlotProps = Omit< + ExternalProps, + keyof UseSliderRootSlotOwnProps +> & + UseSliderRootSlotOwnProps; + +export type UseSliderThumbSlotOwnProps = { + onMouseLeave: React.MouseEventHandler; + onMouseOver: React.MouseEventHandler; +}; + +export type UseSliderThumbSlotProps = Omit< + ExternalProps, + keyof UseSliderThumbSlotOwnProps +> & + UseSliderThumbSlotOwnProps; + +export type UseSliderHiddenInputOwnProps = { + 'aria-labelledby'?: string; + 'aria-orientation'?: React.AriaAttributes['aria-orientation']; + 'aria-valuemax'?: React.AriaAttributes['aria-valuemax']; + 'aria-valuemin'?: React.AriaAttributes['aria-valuemin']; + disabled: boolean; + name?: string; + onBlur: React.FocusEventHandler; + onChange: React.ChangeEventHandler; + onFocus: React.FocusEventHandler; + step?: number | 'any'; + style: React.CSSProperties; + tabIndex?: number; + type?: React.InputHTMLAttributes['type']; +}; + +export type UseSliderHiddenInputProps = Omit< + ExternalProps, + keyof UseSliderHiddenInputOwnProps +> & + UseSliderHiddenInputOwnProps; + +export type Axis = 'horizontal' | 'vertical' | 'horizontal-reverse'; + +export interface AxisProps { + offset: ( + percent: number, + ) => T extends 'horizontal' + ? { left: string } + : T extends 'vertical' + ? { bottom: string } + : T extends 'horizontal-reverse' + ? { right: string } + : never; + leap: ( + percent: number, + ) => T extends 'horizontal' | 'horizontal-reverse' + ? { width: string } + : T extends 'vertical' + ? { height: string } + : never; +} + +export interface UseSliderReturnValue { + /** + * The active index of the slider. + */ + active: number; + /** + * The orientation of the slider. + */ + axis: Axis; + /** + * Returns the `offset` and `leap` methods to calculate the positioning styles based on the slider axis. + */ + axisProps: { [key in Axis]: AxisProps }; + /** + * If `true`, the slider is being dragged. + */ + dragging: boolean; + /** + * The index of the thumb which is focused on the slider. + */ + focusedThumbIndex: number; + /** + * Resolver for the hidden input slot's props. + * @param externalProps props for the hidden input slot + * @returns props that should be spread on the hidden input slot + */ + getHiddenInputProps: = {}>( + externalProps?: ExternalProps, + ) => UseSliderHiddenInputProps; + /** + * Resolver for the root slot's props. + * @param externalProps props for the root slot + * @returns props that should be spread on the root slot + */ + getRootProps: = {}>( + externalProps?: ExternalProps, + ) => UseSliderRootSlotProps; + /** + * Resolver for the thumb slot's props. + * @param externalProps props for the thumb slot + * @returns props that should be spread on the thumb slot + */ + getThumbProps: = {}>( + externalProps?: ExternalProps, + ) => UseSliderThumbSlotProps; + /** + * Resolver for the thumb slot's style prop. + * @param index of the currently moved thumb + * @returns props that should be spread on the style prop of thumb slot + */ + getThumbStyle: (index: number) => object; + /** + * The marks of the slider. Marks indicate predetermined values to which the user can move the slider. + */ + marks: Mark[]; + /** + * The thumb index for the current value when in hover state. + */ + open: number; + /** + * If `true`, the slider is a range slider when the `value` prop passed is an array. + */ + range: boolean; + /** + * Ref to the root slot's DOM node. + */ + rootRef: React.RefCallback | null; + /** + * The track leap for the current value of the slider. + */ + trackLeap: number; + /** + * The track offset for the current value of the slider. + */ + trackOffset: number; + /** + * The possible values of the slider. + */ + values: number[]; +} diff --git a/packages/mui-material/src/utils/index.d.ts b/packages/mui-material/src/utils/index.d.ts index 749cce071ace56..bca9cd8e198f86 100644 --- a/packages/mui-material/src/utils/index.d.ts +++ b/packages/mui-material/src/utils/index.d.ts @@ -1,3 +1,4 @@ +export { unstable_ClassNameGenerator } from '@mui/utils'; export { default as capitalize } from './capitalize'; export { default as createChainedFunction } from './createChainedFunction'; export { default as createSvgIcon } from './createSvgIcon'; @@ -14,4 +15,3 @@ export { default as unsupportedProp } from './unsupportedProp'; export { default as useControlled } from './useControlled'; export { default as useEventCallback } from './useEventCallback'; export { default as useForkRef } from './useForkRef'; -export { unstable_ClassNameGenerator } from '@mui/base/ClassNameGenerator'; diff --git a/packages/mui-material/src/utils/index.js b/packages/mui-material/src/utils/index.js index 3a9a621f494112..90b88322517ab1 100644 --- a/packages/mui-material/src/utils/index.js +++ b/packages/mui-material/src/utils/index.js @@ -1,5 +1,5 @@ 'use client'; -import { unstable_ClassNameGenerator as ClassNameGenerator } from '@mui/base/ClassNameGenerator'; +import { unstable_ClassNameGenerator as ClassNameGenerator } from '@mui/utils'; export { default as capitalize } from './capitalize'; export { default as createChainedFunction } from './createChainedFunction'; From 2e71c2833291df9e3ee15b16e28a71a4c1b56493 Mon Sep 17 00:00:00 2001 From: mnajdova Date: Thu, 11 Jul 2024 11:08:44 +0200 Subject: [PATCH 06/24] Move ClickAwayListener and Modal dependencies --- .../ClickAwayListener.test.js | 441 ++++++++++++++++++ .../ClickAwayListener/ClickAwayListener.tsx | 261 +++++++++++ .../src/ClickAwayListener/index.ts | 4 +- packages/mui-material/src/Modal/Modal.js | 2 +- .../src/Modal/ModalManager.test.ts | 435 +++++++++++++++++ .../mui-material/src/Modal/ModalManager.ts | 314 +++++++++++++ packages/mui-material/src/Modal/index.d.ts | 2 +- packages/mui-material/src/Modal/index.js | 2 +- packages/mui-material/src/Modal/useModal.ts | 241 ++++++++++ .../mui-material/src/Modal/useModal.types.ts | 123 +++++ .../mui-material/src/Snackbar/Snackbar.d.ts | 2 +- .../mui-material/src/Snackbar/Snackbar.js | 2 +- 12 files changed, 1822 insertions(+), 7 deletions(-) create mode 100644 packages/mui-material/src/ClickAwayListener/ClickAwayListener.test.js create mode 100644 packages/mui-material/src/ClickAwayListener/ClickAwayListener.tsx create mode 100644 packages/mui-material/src/Modal/ModalManager.test.ts create mode 100644 packages/mui-material/src/Modal/ModalManager.ts create mode 100644 packages/mui-material/src/Modal/useModal.ts create mode 100644 packages/mui-material/src/Modal/useModal.types.ts diff --git a/packages/mui-material/src/ClickAwayListener/ClickAwayListener.test.js b/packages/mui-material/src/ClickAwayListener/ClickAwayListener.test.js new file mode 100644 index 00000000000000..1cbd6e67d15e50 --- /dev/null +++ b/packages/mui-material/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 ( + +
      + + ); + } + render(); + + act(() => { + screen.getByRole('button').click(); + }); + + expect(handleClickAway.callCount).to.equal(0); + }); + }); +}); diff --git a/packages/mui-material/src/ClickAwayListener/ClickAwayListener.tsx b/packages/mui-material/src/ClickAwayListener/ClickAwayListener.tsx new file mode 100644 index 00000000000000..ee00d7d9fc8d2b --- /dev/null +++ b/packages/mui-material/src/ClickAwayListener/ClickAwayListener.tsx @@ -0,0 +1,261 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { + elementAcceptingRef, + exactProp, + unstable_ownerDocument as ownerDocument, + unstable_useForkRef as useForkRef, + unstable_useEventCallback as useEventCallback, +} from '@mui/utils'; + +// TODO: return `EventHandlerName extends `on${infer EventName}` ? Lowercase : never` once generatePropTypes runs with TS 4.1 +function mapEventPropToEvent( + eventProp: ClickAwayMouseEventHandler | ClickAwayTouchEventHandler, +): 'click' | 'mousedown' | 'mouseup' | 'touchstart' | 'touchend' | 'pointerdown' | 'pointerup' { + return eventProp.substring(2).toLowerCase() as any; +} + +function clickedRootScrollbar(event: MouseEvent, doc: Document) { + return ( + doc.documentElement.clientWidth < event.clientX || + doc.documentElement.clientHeight < event.clientY + ); +} + +type ClickAwayMouseEventHandler = + | 'onClick' + | 'onMouseDown' + | 'onMouseUp' + | 'onPointerDown' + | 'onPointerUp'; +type ClickAwayTouchEventHandler = 'onTouchStart' | 'onTouchEnd'; + +export interface ClickAwayListenerProps { + /** + * The wrapped element. + */ + children: React.ReactElement; + /** + * If `true`, the React tree is ignored and only the DOM tree is considered. + * This prop changes how portaled elements are handled. + * @default false + */ + disableReactTree?: boolean; + /** + * The mouse event to listen to. You can disable the listener by providing `false`. + * @default 'onClick' + */ + mouseEvent?: ClickAwayMouseEventHandler | false; + /** + * Callback fired when a "click away" event is detected. + */ + onClickAway: (event: MouseEvent | TouchEvent) => void; + /** + * The touch event to listen to. You can disable the listener by providing `false`. + * @default 'onTouchEnd' + */ + touchEvent?: ClickAwayTouchEventHandler | false; +} + +/** + * Listen for click events that occur somewhere in the document, outside of the element itself. + * For instance, if you need to hide a menu when people click anywhere else on your page. + * + * Demos: + * + * - [Click-Away Listener](https://mui.com/base-ui/react-click-away-listener/) + * + * API: + * + * - [ClickAwayListener API](https://mui.com/base-ui/react-click-away-listener/components-api/#click-away-listener) + */ +function ClickAwayListener(props: ClickAwayListenerProps): React.JSX.Element { + const { + children, + disableReactTree = false, + mouseEvent = 'onClick', + onClickAway, + touchEvent = 'onTouchEnd', + } = props; + const movedRef = React.useRef(false); + const nodeRef = React.useRef(null); + const activatedRef = React.useRef(false); + const syntheticEventRef = React.useRef(false); + + React.useEffect(() => { + // Ensure that this component is not "activated" synchronously. + // https://github.com/facebook/react/issues/20074 + setTimeout(() => { + activatedRef.current = true; + }, 0); + return () => { + activatedRef.current = false; + }; + }, []); + + const handleRef = useForkRef( + // @ts-expect-error TODO upstream fix + children.ref, + nodeRef, + ); + + // The handler doesn't take event.defaultPrevented into account: + // + // event.preventDefault() is meant to stop default behaviors like + // clicking a checkbox to check it, hitting a button to submit a form, + // and hitting left arrow to move the cursor in a text input etc. + // Only special HTML elements have these default behaviors. + const handleClickAway = useEventCallback((event: MouseEvent | TouchEvent) => { + // Given developers can stop the propagation of the synthetic event, + // we can only be confident with a positive value. + const insideReactTree = syntheticEventRef.current; + syntheticEventRef.current = false; + + const doc = ownerDocument(nodeRef.current); + + // 1. IE11 support, which trigger the handleClickAway even after the unbind + // 2. The child might render null. + // 3. Behave like a blur listener. + if ( + !activatedRef.current || + !nodeRef.current || + ('clientX' in event && clickedRootScrollbar(event, doc)) + ) { + return; + } + + // Do not act if user performed touchmove + if (movedRef.current) { + movedRef.current = false; + return; + } + + let insideDOM; + + // If not enough, can use https://github.com/DieterHolvoet/event-propagation-path/blob/master/propagationPath.js + if (event.composedPath) { + insideDOM = event.composedPath().indexOf(nodeRef.current) > -1; + } 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, + ); + } + + if (!insideDOM && (disableReactTree || !insideReactTree)) { + onClickAway(event); + } + }); + + // Keep track of mouse/touch events that bubbled up through the portal. + const createHandleSynthetic = (handlerName: string) => (event: React.SyntheticEvent) => { + syntheticEventRef.current = true; + + const childrenPropsHandler = children.props[handlerName]; + if (childrenPropsHandler) { + childrenPropsHandler(event); + } + }; + + const childrenProps: { ref: React.Ref } & Pick< + React.DOMAttributes, + ClickAwayMouseEventHandler | ClickAwayTouchEventHandler + > = { ref: handleRef }; + + if (touchEvent !== false) { + childrenProps[touchEvent] = createHandleSynthetic(touchEvent); + } + + React.useEffect(() => { + if (touchEvent !== false) { + const mappedTouchEvent = mapEventPropToEvent(touchEvent); + const doc = ownerDocument(nodeRef.current); + + const handleTouchMove = () => { + movedRef.current = true; + }; + + doc.addEventListener(mappedTouchEvent, handleClickAway); + doc.addEventListener('touchmove', handleTouchMove); + + return () => { + doc.removeEventListener(mappedTouchEvent, handleClickAway); + doc.removeEventListener('touchmove', handleTouchMove); + }; + } + + return undefined; + }, [handleClickAway, touchEvent]); + + if (mouseEvent !== false) { + childrenProps[mouseEvent] = createHandleSynthetic(mouseEvent); + } + + React.useEffect(() => { + if (mouseEvent !== false) { + const mappedMouseEvent = mapEventPropToEvent(mouseEvent); + const doc = ownerDocument(nodeRef.current); + + doc.addEventListener(mappedMouseEvent, handleClickAway); + + return () => { + doc.removeEventListener(mappedMouseEvent, handleClickAway); + }; + } + + return undefined; + }, [handleClickAway, mouseEvent]); + + return {React.cloneElement(children, childrenProps)}; +} + +ClickAwayListener.propTypes /* remove-proptypes */ = { + // ┌────────────────────────────── Warning ──────────────────────────────┐ + // │ These PropTypes are generated from the TypeScript type definitions. │ + // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ + // └─────────────────────────────────────────────────────────────────────┘ + /** + * The wrapped element. + */ + children: elementAcceptingRef.isRequired, + /** + * If `true`, the React tree is ignored and only the DOM tree is considered. + * This prop changes how portaled elements are handled. + * @default false + */ + disableReactTree: PropTypes.bool, + /** + * The mouse event to listen to. You can disable the listener by providing `false`. + * @default 'onClick' + */ + mouseEvent: PropTypes.oneOf([ + 'onClick', + 'onMouseDown', + 'onMouseUp', + 'onPointerDown', + 'onPointerUp', + false, + ]), + /** + * Callback fired when a "click away" event is detected. + */ + onClickAway: PropTypes.func.isRequired, + /** + * The touch event to listen to. You can disable the listener by providing `false`. + * @default 'onTouchEnd' + */ + touchEvent: PropTypes.oneOf(['onTouchEnd', 'onTouchStart', false]), +} as any; + +if (process.env.NODE_ENV !== 'production') { + // eslint-disable-next-line + (ClickAwayListener as any)['propTypes' + ''] = exactProp(ClickAwayListener.propTypes); +} + +export { ClickAwayListener }; diff --git a/packages/mui-material/src/ClickAwayListener/index.ts b/packages/mui-material/src/ClickAwayListener/index.ts index 855d5f5f3234ca..46033c2d1141e6 100644 --- a/packages/mui-material/src/ClickAwayListener/index.ts +++ b/packages/mui-material/src/ClickAwayListener/index.ts @@ -1,2 +1,2 @@ -export { ClickAwayListener as default } from '@mui/base/ClickAwayListener'; -export type { ClickAwayListenerProps } from '@mui/base/ClickAwayListener'; +export { ClickAwayListener as default } from './ClickAwayListener'; +export type { ClickAwayListenerProps } from './ClickAwayListener'; diff --git a/packages/mui-material/src/Modal/Modal.js b/packages/mui-material/src/Modal/Modal.js index feb588bc59c2b6..1829e17f00f34d 100644 --- a/packages/mui-material/src/Modal/Modal.js +++ b/packages/mui-material/src/Modal/Modal.js @@ -4,8 +4,8 @@ import PropTypes from 'prop-types'; import clsx from 'clsx'; import HTMLElementType from '@mui/utils/HTMLElementType'; import elementAcceptingRef from '@mui/utils/elementAcceptingRef'; -import { unstable_useModal as useModal } from '@mui/base/unstable_useModal'; import composeClasses from '@mui/utils/composeClasses'; +import { useModal } from './useModal'; import FocusTrap from '../Unstable_TrapFocus'; import Portal from '../Portal'; import { styled } from '../zero-styled'; diff --git a/packages/mui-material/src/Modal/ModalManager.test.ts b/packages/mui-material/src/Modal/ModalManager.test.ts new file mode 100644 index 00000000000000..a53aa83068124b --- /dev/null +++ b/packages/mui-material/src/Modal/ModalManager.test.ts @@ -0,0 +1,435 @@ +import { expect } from 'chai'; +import { unstable_getScrollbarSize as getScrollbarSize } from '@mui/utils'; +import { ModalManager } from './ModalManager'; + +interface Modal { + mount: Element; + modalRef: Element; +} + +function getDummyModal(): Modal { + return { + mount: document.createElement('div'), + modalRef: document.createElement('div'), + }; +} + +describe('ModalManager', () => { + let modalManager: ModalManager; + let container1: HTMLDivElement; + + before(() => { + modalManager = new ModalManager(); + container1 = document.createElement('div'); + container1.style.paddingRight = '20px'; + Object.defineProperty(container1, 'scrollHeight', { + value: 100, + writable: false, + }); + Object.defineProperty(container1, 'clientHeight', { + value: 90, + writable: false, + }); + document.body.appendChild(container1); + }); + + after(() => { + document.body.removeChild(container1); + }); + + it('should add a modal only once', () => { + const modal = getDummyModal(); + const modalManager2 = new ModalManager(); + const idx = modalManager2.add(modal, container1); + modalManager2.mount(modal, {}); + expect(modalManager2.add(modal, container1)).to.equal(idx); + modalManager2.remove(modal); + }); + + describe('managing modals', () => { + let modal1: Modal; + let modal2: Modal; + let modal3: Modal; + + before(() => { + modal1 = getDummyModal(); + modal2 = getDummyModal(); + modal3 = getDummyModal(); + }); + + it('should add modal1', () => { + const idx = modalManager.add(modal1, container1); + modalManager.mount(modal1, {}); + expect(idx).to.equal(0); + expect(modalManager.isTopModal(modal1)).to.equal(true); + }); + + it('should add modal2', () => { + const idx = modalManager.add(modal2, container1); + expect(idx).to.equal(1); + expect(modalManager.isTopModal(modal2)).to.equal(true); + }); + + it('should add modal3', () => { + const idx = modalManager.add(modal3, container1); + expect(idx).to.equal(2); + expect(modalManager.isTopModal(modal3)).to.equal(true); + }); + + it('should remove modal2', () => { + const idx = modalManager.remove(modal2); + expect(idx).to.equal(1); + }); + + it('should add modal2 2', () => { + const idx = modalManager.add(modal2, container1); + modalManager.mount(modal2, {}); + expect(idx).to.equal(2); + expect(modalManager.isTopModal(modal2)).to.equal(true); + expect(modalManager.isTopModal(modal3)).to.equal(false); + }); + + it('should remove modal3', () => { + const idx = modalManager.remove(modal3); + expect(idx).to.equal(1); + }); + + it('should remove modal2 2', () => { + const idx = modalManager.remove(modal2); + expect(idx).to.equal(1); + expect(modalManager.isTopModal(modal1)).to.equal(true); + }); + + it('should remove modal1', () => { + const idx = modalManager.remove(modal1); + expect(idx).to.equal(0); + }); + + it('should not do anything', () => { + const idx = modalManager.remove(getDummyModal()); + expect(idx).to.equal(-1); + }); + }); + + describe('overflow', () => { + let fixedNode: HTMLDivElement; + + beforeEach(() => { + container1.style.paddingRight = '20px'; + + fixedNode = document.createElement('div'); + fixedNode.classList.add('mui-fixed'); + document.body.appendChild(fixedNode); + window.innerWidth += 1; // simulate a scrollbar + }); + + afterEach(() => { + document.body.removeChild(fixedNode); + window.innerWidth -= 1; + }); + + it('should handle the scroll', () => { + fixedNode.style.paddingRight = '14px'; + + const modal = getDummyModal(); + modalManager.add(modal, container1); + modalManager.mount(modal, {}); + expect(container1.style.overflow).to.equal('hidden'); + expect(container1.style.paddingRight).to.equal(`${20 + getScrollbarSize(document)}px`); + expect(fixedNode.style.paddingRight).to.equal(`${14 + getScrollbarSize(document)}px`); + modalManager.remove(modal); + expect(container1.style.overflow).to.equal(''); + expect(container1.style.paddingRight).to.equal('20px'); + expect(fixedNode.style.paddingRight).to.equal('14px'); + }); + + it('should disable the scroll even when not overflowing', () => { + // simulate non-overflowing container + const container2 = document.createElement('div'); + Object.defineProperty(container2, 'scrollHeight', { + value: 100, + writable: false, + }); + Object.defineProperty(container2, 'clientHeight', { + value: 100, + writable: false, + }); + document.body.appendChild(container2); + + const modal = getDummyModal(); + modalManager.add(modal, container2); + modalManager.mount(modal, {}); + expect(container2.style.overflow).to.equal('hidden'); + modalManager.remove(modal); + expect(container2.style.overflow).to.equal(''); + + document.body.removeChild(container2); + }); + + it('should restore styles correctly if none existed before', () => { + const modal = getDummyModal(); + modalManager.add(modal, container1); + modalManager.mount(modal, {}); + expect(container1.style.overflow).to.equal('hidden'); + expect(container1.style.paddingRight).to.equal(`${20 + getScrollbarSize(document)}px`); + expect(fixedNode.style.paddingRight).to.equal(`${getScrollbarSize(document)}px`); + modalManager.remove(modal); + expect(container1.style.overflow).to.equal(''); + expect(container1.style.paddingRight).to.equal('20px'); + expect(fixedNode.style.paddingRight).to.equal(''); + }); + + describe('shadow dom', () => { + let shadowContainer: HTMLDivElement; + let container2: HTMLDivElement; + + beforeEach(() => { + shadowContainer = document.createElement('div'); + const shadowRoot = shadowContainer.attachShadow({ mode: 'open' }); + container2 = document.createElement('div'); + shadowRoot.appendChild(container2); + }); + + afterEach(() => { + document.body.removeChild(shadowContainer); + }); + + it('should scroll body when parent is shadow root', () => { + const modal = getDummyModal(); + + container2.style.overflow = 'scroll'; + + document.body.appendChild(shadowContainer); + modalManager.add(modal, container2); + modalManager.mount(modal, {}); + + expect(container2.style.overflow).to.equal('scroll'); + expect(document.body.style.overflow).to.equal('hidden'); + modalManager.remove(modal); + + expect(container2.style.overflow).to.equal('scroll'); + expect(document.body.style.overflow).to.equal(''); + }); + }); + + describe('restore styles', () => { + let container2: HTMLDivElement; + + beforeEach(() => { + container2 = document.createElement('div'); + }); + + afterEach(() => { + document.body.removeChild(container2); + }); + + it('should restore styles correctly if overflow existed before', () => { + const modal = getDummyModal(); + + container2.style.overflow = 'scroll'; + + Object.defineProperty(container2, 'scrollHeight', { + value: 100, + writable: false, + }); + Object.defineProperty(container2, 'clientHeight', { + value: 90, + writable: false, + }); + + document.body.appendChild(container2); + modalManager.add(modal, container2); + modalManager.mount(modal, {}); + + expect(container2.style.overflow).to.equal('hidden'); + modalManager.remove(modal); + + expect(container2.style.overflow).to.equal('scroll'); + expect(fixedNode.style.paddingRight).to.equal(''); + }); + + it('should restore styles correctly if overflow-x existed before', () => { + const modal = getDummyModal(); + + container2.style.overflowX = 'hidden'; + + Object.defineProperty(container2, 'scrollHeight', { + value: 100, + writable: false, + }); + Object.defineProperty(container2, 'clientHeight', { + value: 90, + writable: false, + }); + + document.body.appendChild(container2); + + modalManager.add(modal, container2); + modalManager.mount(modal, {}); + + expect(container2.style.overflow).to.equal('hidden'); + + modalManager.remove(modal); + + expect(container2.style.overflow).to.equal(''); + expect(container2.style.overflowX).to.equal('hidden'); + }); + }); + }); + + describe('multi container', () => { + let container3: HTMLDivElement; + let container4: HTMLDivElement; + + beforeEach(() => { + container3 = document.createElement('div'); + document.body.appendChild(container3); + container3.appendChild(document.createElement('div')); + + container4 = document.createElement('div'); + document.body.appendChild(container4); + container4.appendChild(document.createElement('div')); + }); + + it('should work will multiple containers', () => { + modalManager = new ModalManager(); + const modal1 = getDummyModal(); + const modal2 = getDummyModal(); + modalManager.add(modal1, container3); + modalManager.mount(modal1, {}); + expect(container3.children[0]).toBeAriaHidden(); + + modalManager.add(modal2, container4); + modalManager.mount(modal2, {}); + expect(container4.children[0]).toBeAriaHidden(); + + modalManager.remove(modal2); + expect(container4.children[0]).not.toBeAriaHidden(); + + modalManager.remove(modal1); + expect(container3.children[0]).not.toBeAriaHidden(); + }); + + afterEach(() => { + document.body.removeChild(container3); + document.body.removeChild(container4); + }); + }); + + describe('container aria-hidden', () => { + let modalRef1; + let container2: HTMLDivElement; + + beforeEach(() => { + container2 = document.createElement('div'); + document.body.appendChild(container2); + + modalRef1 = document.createElement('div'); + container2.appendChild(modalRef1); + + modalManager = new ModalManager(); + }); + + afterEach(() => { + document.body.removeChild(container2); + }); + + it('should not contain aria-hidden on modal', () => { + const modal2 = document.createElement('div'); + modal2.setAttribute('aria-hidden', 'true'); + + expect(modal2).toBeAriaHidden(); + modalManager.add({ ...getDummyModal(), modalRef: modal2 }, container2); + expect(modal2).not.toBeAriaHidden(); + }); + + it('should add aria-hidden to container siblings', () => { + const secondSibling = document.createElement('input'); + container2.appendChild(secondSibling); + modalManager.add(getDummyModal(), container2); + expect(container2.children[0]).toBeAriaHidden(); + expect(container2.children[1]).toBeAriaHidden(); + }); + + it('should not add aria-hidden to forbidden container siblings', () => { + [ + 'template', + 'script', + 'style', + 'link', + 'map', + 'meta', + 'noscript', + 'picture', + 'col', + 'colgroup', + 'param', + 'slot', + 'source', + 'track', + ].forEach(function createBlacklistSiblings(name) { + const sibling = document.createElement(name); + container2.appendChild(sibling); + }); + const inputHiddenSibling = document.createElement('input'); + inputHiddenSibling.setAttribute('type', 'hidden'); + container2.appendChild(inputHiddenSibling); + + const numberOfChildren = 16; + expect(container2.children.length).equal(numberOfChildren); + + modalManager.add(getDummyModal(), container2); + expect(container2.children[0]).toBeAriaHidden(); + for (let i = 1; i < numberOfChildren; i += 1) { + expect(container2.children[i]).not.toBeAriaHidden(); + } + }); + + it('should add aria-hidden to previous modals', () => { + const modal2 = document.createElement('div'); + const modal3 = document.createElement('div'); + + container2.appendChild(modal2); + container2.appendChild(modal3); + + modalManager.add({ ...getDummyModal(), modalRef: modal2 }, container2); + // Simulate the main React DOM true. + expect(container2.children[0]).toBeAriaHidden(); + expect(container2.children[1]).not.toBeAriaHidden(); + + modalManager.add({ ...getDummyModal(), modalRef: modal3 }, container2); + expect(container2.children[0]).toBeAriaHidden(); + expect(container2.children[1]).toBeAriaHidden(); + expect(container2.children[2]).not.toBeAriaHidden(); + }); + + it('should remove aria-hidden on siblings', () => { + const modal = { ...getDummyModal(), modalRef: container2.children[0] }; + + modalManager.add(modal, container2); + modalManager.mount(modal, {}); + expect(container2.children[0]).not.toBeAriaHidden(); + modalManager.remove(modal); + expect(container2.children[0]).toBeAriaHidden(); + }); + + it('should keep previous aria-hidden siblings hidden', () => { + const modal = { ...getDummyModal(), modalRef: container2.children[0] }; + const sibling1 = document.createElement('div'); + const sibling2 = document.createElement('div'); + + sibling1.setAttribute('aria-hidden', 'true'); + + container2.appendChild(sibling1); + container2.appendChild(sibling2); + + modalManager.add(modal, container2); + modalManager.mount(modal, {}); + expect(container2.children[0]).not.toBeAriaHidden(); + modalManager.remove(modal); + expect(container2.children[0]).toBeAriaHidden(); + expect(container2.children[1]).toBeAriaHidden(); + expect(container2.children[2]).not.toBeAriaHidden(); + }); + }); +}); diff --git a/packages/mui-material/src/Modal/ModalManager.ts b/packages/mui-material/src/Modal/ModalManager.ts new file mode 100644 index 00000000000000..81470c4864f831 --- /dev/null +++ b/packages/mui-material/src/Modal/ModalManager.ts @@ -0,0 +1,314 @@ +import { + unstable_ownerWindow as ownerWindow, + unstable_ownerDocument as ownerDocument, + unstable_getScrollbarSize as getScrollbarSize, +} from '@mui/utils'; + +export interface ManagedModalProps { + disableScrollLock?: boolean; +} + +// Is a vertical scrollbar displayed? +function isOverflowing(container: Element): boolean { + const doc = ownerDocument(container); + + if (doc.body === container) { + return ownerWindow(container).innerWidth > doc.documentElement.clientWidth; + } + + return container.scrollHeight > container.clientHeight; +} + +export function ariaHidden(element: Element, show: boolean): void { + if (show) { + element.setAttribute('aria-hidden', 'true'); + } else { + element.removeAttribute('aria-hidden'); + } +} + +function getPaddingRight(element: Element): number { + return parseInt(ownerWindow(element).getComputedStyle(element).paddingRight, 10) || 0; +} + +function isAriaHiddenForbiddenOnElement(element: Element): boolean { + // The forbidden HTML tags are the ones from ARIA specification that + // can be children of body and can't have aria-hidden attribute. + // cf. https://www.w3.org/TR/html-aria/#docconformance + const forbiddenTagNames = [ + 'TEMPLATE', + 'SCRIPT', + 'STYLE', + 'LINK', + 'MAP', + 'META', + 'NOSCRIPT', + 'PICTURE', + 'COL', + 'COLGROUP', + 'PARAM', + 'SLOT', + 'SOURCE', + 'TRACK', + ]; + const isForbiddenTagName = forbiddenTagNames.indexOf(element.tagName) !== -1; + const isInputHidden = element.tagName === 'INPUT' && element.getAttribute('type') === 'hidden'; + return isForbiddenTagName || isInputHidden; +} + +function ariaHiddenSiblings( + container: Element, + mountElement: Element, + currentElement: Element, + elementsToExclude: readonly Element[], + show: boolean, +): void { + const blacklist = [mountElement, currentElement, ...elementsToExclude]; + + [].forEach.call(container.children, (element: Element) => { + const isNotExcludedElement = blacklist.indexOf(element) === -1; + const isNotForbiddenElement = !isAriaHiddenForbiddenOnElement(element); + if (isNotExcludedElement && isNotForbiddenElement) { + ariaHidden(element, show); + } + }); +} + +function findIndexOf(items: readonly T[], callback: (item: T) => boolean): number { + let idx = -1; + items.some((item, index) => { + if (callback(item)) { + idx = index; + return true; + } + return false; + }); + return idx; +} + +function handleContainer(containerInfo: Container, props: ManagedModalProps) { + const restoreStyle: Array<{ + /** + * CSS property name (HYPHEN CASE) to be modified. + */ + property: string; + el: HTMLElement | SVGElement; + value: string; + }> = []; + const container = containerInfo.container; + + if (!props.disableScrollLock) { + if (isOverflowing(container)) { + // Compute the size before applying overflow hidden to avoid any scroll jumps. + const scrollbarSize = getScrollbarSize(ownerDocument(container)); + + restoreStyle.push({ + value: container.style.paddingRight, + property: 'padding-right', + el: container, + }); + // Use computed style, here to get the real padding to add our scrollbar width. + container.style.paddingRight = `${getPaddingRight(container) + scrollbarSize}px`; + + // .mui-fixed is a global helper. + const fixedElements = ownerDocument(container).querySelectorAll('.mui-fixed'); + [].forEach.call(fixedElements, (element: HTMLElement | SVGElement) => { + restoreStyle.push({ + value: element.style.paddingRight, + property: 'padding-right', + el: element, + }); + element.style.paddingRight = `${getPaddingRight(element) + scrollbarSize}px`; + }); + } + + let scrollContainer: HTMLElement; + + if (container.parentNode instanceof DocumentFragment) { + scrollContainer = ownerDocument(container).body; + } else { + // Support html overflow-y: auto for scroll stability between pages + // https://css-tricks.com/snippets/css/force-vertical-scrollbar/ + const parent = container.parentElement; + const containerWindow = ownerWindow(container); + scrollContainer = + parent?.nodeName === 'HTML' && + containerWindow.getComputedStyle(parent).overflowY === 'scroll' + ? parent + : container; + } + + // Block the scroll even if no scrollbar is visible to account for mobile keyboard + // screensize shrink. + restoreStyle.push( + { + value: scrollContainer.style.overflow, + property: 'overflow', + el: scrollContainer, + }, + { + value: scrollContainer.style.overflowX, + property: 'overflow-x', + el: scrollContainer, + }, + { + value: scrollContainer.style.overflowY, + property: 'overflow-y', + el: scrollContainer, + }, + ); + + scrollContainer.style.overflow = 'hidden'; + } + + const restore = () => { + restoreStyle.forEach(({ value, el, property }) => { + if (value) { + el.style.setProperty(property, value); + } else { + el.style.removeProperty(property); + } + }); + }; + + return restore; +} + +function getHiddenSiblings(container: Element) { + const hiddenSiblings: Element[] = []; + [].forEach.call(container.children, (element: Element) => { + if (element.getAttribute('aria-hidden') === 'true') { + hiddenSiblings.push(element); + } + }); + return hiddenSiblings; +} + +interface Modal { + mount: Element; + modalRef: Element; +} + +interface Container { + container: HTMLElement; + hiddenSiblings: Element[]; + modals: Modal[]; + restore: null | (() => void); +} + +/** + * @ignore - do not document. + * + * Proper state management for containers and the modals in those containers. + * Simplified, but inspired by react-overlay's ModalManager class. + * Used by the Modal to ensure proper styling of containers. + */ +export class ModalManager { + private containers: Container[]; + + private modals: Modal[]; + + constructor() { + this.modals = []; + this.containers = []; + } + + add(modal: Modal, container: HTMLElement): number { + let modalIndex = this.modals.indexOf(modal); + if (modalIndex !== -1) { + return modalIndex; + } + + modalIndex = this.modals.length; + this.modals.push(modal); + + // If the modal we are adding is already in the DOM. + if (modal.modalRef) { + ariaHidden(modal.modalRef, false); + } + + const hiddenSiblings = getHiddenSiblings(container); + ariaHiddenSiblings(container, modal.mount, modal.modalRef, hiddenSiblings, true); + + const containerIndex = findIndexOf(this.containers, (item) => item.container === container); + if (containerIndex !== -1) { + this.containers[containerIndex].modals.push(modal); + return modalIndex; + } + + this.containers.push({ + modals: [modal], + container, + restore: null, + hiddenSiblings, + }); + + return modalIndex; + } + + mount(modal: Modal, props: ManagedModalProps): void { + const containerIndex = findIndexOf( + this.containers, + (item) => item.modals.indexOf(modal) !== -1, + ); + const containerInfo = this.containers[containerIndex]; + + if (!containerInfo.restore) { + containerInfo.restore = handleContainer(containerInfo, props); + } + } + + remove(modal: Modal, ariaHiddenState = true): number { + const modalIndex = this.modals.indexOf(modal); + + if (modalIndex === -1) { + return modalIndex; + } + + const containerIndex = findIndexOf( + this.containers, + (item) => item.modals.indexOf(modal) !== -1, + ); + const containerInfo = this.containers[containerIndex]; + + containerInfo.modals.splice(containerInfo.modals.indexOf(modal), 1); + this.modals.splice(modalIndex, 1); + + // If that was the last modal in a container, clean up the container. + if (containerInfo.modals.length === 0) { + // The modal might be closed before it had the chance to be mounted in the DOM. + if (containerInfo.restore) { + containerInfo.restore(); + } + + if (modal.modalRef) { + // In case the modal wasn't in the DOM yet. + ariaHidden(modal.modalRef, ariaHiddenState); + } + + ariaHiddenSiblings( + containerInfo.container, + modal.mount, + modal.modalRef, + containerInfo.hiddenSiblings, + false, + ); + this.containers.splice(containerIndex, 1); + } else { + // Otherwise make sure the next top modal is visible to a screen reader. + const nextTop = containerInfo.modals[containerInfo.modals.length - 1]; + // as soon as a modal is adding its modalRef is undefined. it can't set + // aria-hidden because the dom element doesn't exist either + // when modal was unmounted before modalRef gets null + if (nextTop.modalRef) { + ariaHidden(nextTop.modalRef, false); + } + } + + return modalIndex; + } + + isTopModal(modal: Modal): boolean { + return this.modals.length > 0 && this.modals[this.modals.length - 1] === modal; + } +} diff --git a/packages/mui-material/src/Modal/index.d.ts b/packages/mui-material/src/Modal/index.d.ts index 2a94278b0225b5..60f0c02a03ccf2 100644 --- a/packages/mui-material/src/Modal/index.d.ts +++ b/packages/mui-material/src/Modal/index.d.ts @@ -1,4 +1,4 @@ -export { ModalManager } from '@mui/base/unstable_useModal'; // exporting ModalManager +export { ModalManager } from './ModalManager'; export { default } from './Modal'; export * from './Modal'; diff --git a/packages/mui-material/src/Modal/index.js b/packages/mui-material/src/Modal/index.js index 44d6b940d2800e..c2add9e2091ead 100644 --- a/packages/mui-material/src/Modal/index.js +++ b/packages/mui-material/src/Modal/index.js @@ -1,5 +1,5 @@ 'use client'; -export { ModalManager } from '@mui/base/unstable_useModal'; +export { ModalManager } from './ModalManager'; export { default } from './Modal'; diff --git a/packages/mui-material/src/Modal/useModal.ts b/packages/mui-material/src/Modal/useModal.ts new file mode 100644 index 00000000000000..602be38c7a8d79 --- /dev/null +++ b/packages/mui-material/src/Modal/useModal.ts @@ -0,0 +1,241 @@ +'use client'; +import * as React from 'react'; +import { + unstable_ownerDocument as ownerDocument, + unstable_useForkRef as useForkRef, + unstable_useEventCallback as useEventCallback, + unstable_createChainedFunction as createChainedFunction, +} from '@mui/utils'; +import { EventHandlers } from '../utils/types'; +import { extractEventHandlers } from '../utils/extractEventHandlers'; +import { ModalManager, ariaHidden } from './ModalManager'; +import { + UseModalParameters, + UseModalReturnValue, + UseModalRootSlotProps, + UseModalBackdropSlotProps, +} from './useModal.types'; + +function getContainer(container: UseModalParameters['container']) { + return typeof container === 'function' ? container() : container; +} + +function getHasTransition(children: UseModalParameters['children']) { + return children ? children.props.hasOwnProperty('in') : false; +} + +// A modal manager used to track and manage the state of open Modals. +// Modals don't open on the server so this won't conflict with concurrent requests. +const defaultManager = new ModalManager(); +/** + * + * Demos: + * + * - [Modal](https://next.mui.com/base-ui/react-modal/#hook) + * + * API: + * + * - [useModal API](https://next.mui.com/base-ui/react-modal/hooks-api/#use-modal) + */ +export function useModal(parameters: UseModalParameters): UseModalReturnValue { + const { + container, + disableEscapeKeyDown = false, + disableScrollLock = false, + // @ts-ignore internal logic - Base UI supports the manager as a prop too + manager = defaultManager, + closeAfterTransition = false, + onTransitionEnter, + onTransitionExited, + children, + onClose, + open, + rootRef, + } = parameters; + + // @ts-ignore internal logic + const modal = React.useRef<{ modalRef: HTMLDivElement; mount: HTMLElement }>({}); + const mountNodeRef = React.useRef(null); + const modalRef = React.useRef(null); + const handleRef = useForkRef(modalRef, rootRef); + const [exited, setExited] = React.useState(!open); + const hasTransition = getHasTransition(children); + + let ariaHiddenProp = true; + if (parameters['aria-hidden'] === 'false' || parameters['aria-hidden'] === false) { + ariaHiddenProp = false; + } + + const getDoc = () => ownerDocument(mountNodeRef.current); + const getModal = () => { + modal.current.modalRef = modalRef.current!; + modal.current.mount = mountNodeRef.current!; + return modal.current; + }; + + const handleMounted = () => { + manager.mount(getModal(), { disableScrollLock }); + + // Fix a bug on Chrome where the scroll isn't initially 0. + if (modalRef.current) { + modalRef.current.scrollTop = 0; + } + }; + + const handleOpen = useEventCallback(() => { + const resolvedContainer = getContainer(container) || getDoc().body; + + manager.add(getModal(), resolvedContainer); + + // The element was already mounted. + if (modalRef.current) { + handleMounted(); + } + }); + + const isTopModal = React.useCallback(() => manager.isTopModal(getModal()), [manager]); + + const handlePortalRef = useEventCallback((node: HTMLElement) => { + mountNodeRef.current = node; + + if (!node) { + return; + } + + if (open && isTopModal()) { + handleMounted(); + } else if (modalRef.current) { + ariaHidden(modalRef.current, ariaHiddenProp); + } + }); + + const handleClose = React.useCallback(() => { + manager.remove(getModal(), ariaHiddenProp); + }, [ariaHiddenProp, manager]); + + React.useEffect(() => { + return () => { + handleClose(); + }; + }, [handleClose]); + + React.useEffect(() => { + if (open) { + handleOpen(); + } else if (!hasTransition || !closeAfterTransition) { + handleClose(); + } + }, [open, handleClose, hasTransition, closeAfterTransition, handleOpen]); + + const createHandleKeyDown = (otherHandlers: EventHandlers) => (event: React.KeyboardEvent) => { + otherHandlers.onKeyDown?.(event); + + // The handler doesn't take event.defaultPrevented into account: + // + // event.preventDefault() is meant to stop default behaviors like + // clicking a checkbox to check it, hitting a button to submit a form, + // and hitting left arrow to move the cursor in a text input etc. + // Only special HTML elements have these default behaviors. + if ( + event.key !== 'Escape' || + event.which === 229 || // Wait until IME is settled. + !isTopModal() + ) { + return; + } + + if (!disableEscapeKeyDown) { + // Swallow the event, in case someone is listening for the escape key on the body. + event.stopPropagation(); + + if (onClose) { + onClose(event, 'escapeKeyDown'); + } + } + }; + + const createHandleBackdropClick = (otherHandlers: EventHandlers) => (event: React.MouseEvent) => { + otherHandlers.onClick?.(event); + + if (event.target !== event.currentTarget) { + return; + } + + if (onClose) { + onClose(event, 'backdropClick'); + } + }; + + const getRootProps = ( + otherHandlers: TOther = {} as TOther, + ): UseModalRootSlotProps => { + const propsEventHandlers = extractEventHandlers(parameters) as Partial; + + // The custom event handlers shouldn't be spread on the root element + delete propsEventHandlers.onTransitionEnter; + delete propsEventHandlers.onTransitionExited; + + const externalEventHandlers = { + ...propsEventHandlers, + ...otherHandlers, + }; + + return { + role: 'presentation', + ...externalEventHandlers, + onKeyDown: createHandleKeyDown(externalEventHandlers), + ref: handleRef, + }; + }; + + const getBackdropProps = ( + otherHandlers: TOther = {} as TOther, + ): UseModalBackdropSlotProps => { + const externalEventHandlers = otherHandlers; + + return { + 'aria-hidden': true, + ...externalEventHandlers, + onClick: createHandleBackdropClick(externalEventHandlers), + open, + }; + }; + + const getTransitionProps = () => { + const handleEnter = () => { + setExited(false); + + if (onTransitionEnter) { + onTransitionEnter(); + } + }; + + const handleExited = () => { + setExited(true); + + if (onTransitionExited) { + onTransitionExited(); + } + + if (closeAfterTransition) { + handleClose(); + } + }; + + return { + onEnter: createChainedFunction(handleEnter, children?.props.onEnter), + onExited: createChainedFunction(handleExited, children?.props.onExited), + }; + }; + + return { + getRootProps, + getBackdropProps, + getTransitionProps, + rootRef: handleRef, + portalRef: handlePortalRef, + isTopModal, + exited, + hasTransition, + }; +} diff --git a/packages/mui-material/src/Modal/useModal.types.ts b/packages/mui-material/src/Modal/useModal.types.ts new file mode 100644 index 00000000000000..a67b240a9c5708 --- /dev/null +++ b/packages/mui-material/src/Modal/useModal.types.ts @@ -0,0 +1,123 @@ +import { PortalProps } from '../Portal'; +import { EventHandlers } from '../utils/types'; + +export interface UseModalRootSlotOwnProps { + role: React.AriaRole; + onKeyDown: React.KeyboardEventHandler; + ref: React.RefCallback | null; +} + +export interface UseModalBackdropSlotOwnProps { + 'aria-hidden': React.AriaAttributes['aria-hidden']; + onClick: React.MouseEventHandler; + open?: boolean; +} + +export type UseModalBackdropSlotProps = TOther & UseModalBackdropSlotOwnProps; + +export type UseModalRootSlotProps = TOther & UseModalRootSlotOwnProps; + +export type UseModalParameters = { + 'aria-hidden'?: React.AriaAttributes['aria-hidden']; + /** + * A single child content element. + */ + children: React.ReactElement | undefined | null; + /** + * When set to true the Modal waits until a nested Transition is completed before closing. + * @default false + */ + closeAfterTransition?: boolean; + /** + * An HTML element or function that returns one. + * The `container` will have the portal children appended to it. + * + * You can also provide a callback, which is called in a React layout effect. + * This lets you set the container from a ref, and also makes server-side rendering possible. + * + * By default, it uses the body of the top-level document object, + * so it's simply `document.body` most of the time. + */ + container?: PortalProps['container']; + /** + * If `true`, hitting escape will not fire the `onClose` callback. + * @default false + */ + disableEscapeKeyDown?: boolean; + /** + * Disable the scroll lock behavior. + * @default false + */ + disableScrollLock?: boolean; + /** + * Callback fired when the component requests to be closed. + * The `reason` parameter can optionally be used to control the response to `onClose`. + * + * @param {object} event The event source of the callback. + * @param {string} reason Can be: `"escapeKeyDown"`, `"backdropClick"`. + */ + onClose?: { + bivarianceHack(event: {}, reason: 'backdropClick' | 'escapeKeyDown'): void; + }['bivarianceHack']; + onKeyDown?: React.KeyboardEventHandler; + /** + * A function called when a transition enters. + */ + onTransitionEnter?: () => void; + /** + * A function called when a transition has exited. + */ + onTransitionExited?: () => void; + /** + * If `true`, the component is shown. + */ + open: boolean; + rootRef: React.Ref; +}; + +export interface UseModalReturnValue { + /** + * Resolver for the root slot's props. + * @param externalProps props for the root slot + * @returns props that should be spread on the root slot + */ + getRootProps: ( + externalProps?: TOther, + ) => UseModalRootSlotProps; + /** + * Resolver for the backdrop slot's props. + * @param externalProps props for the backdrop slot + * @returns props that should be spread on the backdrop slot + */ + getBackdropProps: ( + externalProps?: TOther, + ) => UseModalBackdropSlotProps; + /** + * Resolver for the transition related props. + * @param externalProps props for the transition element + * @returns props that should be spread on the transition element + */ + getTransitionProps: ( + externalProps?: TOther, + ) => { onEnter: () => void; onExited: () => void }; + /** + * A ref to the component's root DOM element. + */ + rootRef: React.RefCallback | null; + /** + * A ref to the component's portal DOM element. + */ + portalRef: React.RefCallback | null; + /** + * If `true`, the modal is the top most one. + */ + isTopModal: () => boolean; + /** + * If `true`, the exiting transition finished (to be used for unmounting the component). + */ + exited: boolean; + /** + * If `true`, the component's child is transition component. + */ + hasTransition: boolean; +} diff --git a/packages/mui-material/src/Snackbar/Snackbar.d.ts b/packages/mui-material/src/Snackbar/Snackbar.d.ts index 1667bb9bbdde46..3128d00d125cc8 100644 --- a/packages/mui-material/src/Snackbar/Snackbar.d.ts +++ b/packages/mui-material/src/Snackbar/Snackbar.d.ts @@ -1,6 +1,6 @@ import * as React from 'react'; import { SxProps } from '@mui/system'; -import { ClickAwayListenerProps } from '@mui/base/ClickAwayListener'; +import { ClickAwayListenerProps } from '../ClickAwayListener'; import { Theme } from '../styles'; import { InternalStandardProps as StandardProps } from '..'; import { SnackbarContentProps } from '../SnackbarContent'; diff --git a/packages/mui-material/src/Snackbar/Snackbar.js b/packages/mui-material/src/Snackbar/Snackbar.js index ffa25df207a30e..9f3b65096b2518 100644 --- a/packages/mui-material/src/Snackbar/Snackbar.js +++ b/packages/mui-material/src/Snackbar/Snackbar.js @@ -2,8 +2,8 @@ import * as React from 'react'; import PropTypes from 'prop-types'; import composeClasses from '@mui/utils/composeClasses'; -import { ClickAwayListener } from '@mui/base/ClickAwayListener'; import { useSnackbar } from '@mui/base/useSnackbar'; +import ClickAwayListener from '../ClickAwayListener'; import { styled, useTheme } from '../zero-styled'; import { useSlotProps } from '../utils/useSlotProps'; import { useDefaultProps } from '../DefaultPropsProvider'; From 09efcda8b758388ed1f67c0a5b247b45cfc162ef Mon Sep 17 00:00:00 2001 From: mnajdova Date: Thu, 11 Jul 2024 11:20:43 +0200 Subject: [PATCH 07/24] move Popper, FocusTrap --- .../ClickAwayListener.test.js | 4 +- .../mui-material/src/Popper/BasePopper.tsx | 546 ++++++++++++++++++ .../src/Popper/BasePopper.types.ts | 154 +++++ .../mui-material/src/Popper/popperClasses.ts | 15 + .../src/Unstable_TrapFocus/FocusTrap.test.tsx | 410 +++++++++++++ .../src/Unstable_TrapFocus/FocusTrap.tsx | 434 ++++++++++++++ .../src/Unstable_TrapFocus/FocusTrap.types.ts | 52 ++ .../src/Unstable_TrapFocus/index.d.ts | 4 +- .../src/utils/PolymorphicComponent.ts | 25 + 9 files changed, 1640 insertions(+), 4 deletions(-) create mode 100644 packages/mui-material/src/Popper/BasePopper.tsx create mode 100644 packages/mui-material/src/Popper/BasePopper.types.ts create mode 100644 packages/mui-material/src/Popper/popperClasses.ts create mode 100644 packages/mui-material/src/Unstable_TrapFocus/FocusTrap.test.tsx create mode 100644 packages/mui-material/src/Unstable_TrapFocus/FocusTrap.tsx create mode 100644 packages/mui-material/src/Unstable_TrapFocus/FocusTrap.types.ts create mode 100644 packages/mui-material/src/utils/PolymorphicComponent.ts diff --git a/packages/mui-material/src/ClickAwayListener/ClickAwayListener.test.js b/packages/mui-material/src/ClickAwayListener/ClickAwayListener.test.js index 1cbd6e67d15e50..e0c4c3730a4a82 100644 --- a/packages/mui-material/src/ClickAwayListener/ClickAwayListener.test.js +++ b/packages/mui-material/src/ClickAwayListener/ClickAwayListener.test.js @@ -9,8 +9,8 @@ import { fireDiscreteEvent, screen, } from '@mui/internal-test-utils'; -import { Portal } from '@mui/base/Portal'; -import { ClickAwayListener } from '@mui/base/ClickAwayListener'; +import Portal from '@mui/material/Portal'; +import ClickAwayListener from '@mui/material/ClickAwayListener'; describe('', () => { const { render: clientRender, clock } = createRenderer({ clock: 'fake' }); diff --git a/packages/mui-material/src/Popper/BasePopper.tsx b/packages/mui-material/src/Popper/BasePopper.tsx new file mode 100644 index 00000000000000..a6cb2e28404b71 --- /dev/null +++ b/packages/mui-material/src/Popper/BasePopper.tsx @@ -0,0 +1,546 @@ +'use client'; +import * as React from 'react'; +import { + chainPropTypes, + HTMLElementType, + refType, + unstable_ownerDocument as ownerDocument, + unstable_useEnhancedEffect as useEnhancedEffect, + unstable_useForkRef as useForkRef, +} from '@mui/utils'; +import { createPopper, Instance, Modifier, Placement, State, VirtualElement } from '@popperjs/core'; +import PropTypes from 'prop-types'; +import composeClasses from '../utils/composeClasses'; +import { Portal } from '../Portal'; +import { getPopperUtilityClass } from './popperClasses'; +import { useSlotProps } from '../utils/useSlotProps'; +import { WithOptionalOwnerState } from '../utils/types'; +import { PolymorphicComponent } from '../utils/PolymorphicComponent'; +import { + PopperPlacementType, + PopperTooltipProps, + PopperTooltipTypeMap, + PopperChildrenProps, + PopperProps, + PopperRootSlotProps, + PopperTransitionProps, + PopperTypeMap, +} from './BasePopper.types'; + +function flipPlacement(placement?: PopperPlacementType, direction?: 'ltr' | 'rtl') { + if (direction === 'ltr') { + return placement; + } + + switch (placement) { + case 'bottom-end': + return 'bottom-start'; + case 'bottom-start': + return 'bottom-end'; + case 'top-end': + return 'top-start'; + case 'top-start': + return 'top-end'; + default: + return placement; + } +} + +function resolveAnchorEl( + anchorEl: + | VirtualElement + | (() => VirtualElement) + | HTMLElement + | (() => HTMLElement) + | null + | undefined, +): HTMLElement | VirtualElement | null | undefined { + return typeof anchorEl === 'function' ? anchorEl() : anchorEl; +} + +function isHTMLElement(element: HTMLElement | VirtualElement): element is HTMLElement { + return (element as HTMLElement).nodeType !== undefined; +} + +function isVirtualElement(element: HTMLElement | VirtualElement): element is VirtualElement { + return !isHTMLElement(element); +} + +const useUtilityClasses = () => { + const slots = { + root: ['root'], + }; + + return composeClasses(slots, getPopperUtilityClass); +}; + +const defaultPopperOptions = {}; + +const PopperTooltip = React.forwardRef(function PopperTooltip< + RootComponentType extends React.ElementType, +>(props: PopperTooltipProps, forwardedRef: React.ForwardedRef) { + const { + anchorEl, + children, + direction, + disablePortal, + modifiers, + open, + placement: initialPlacement, + popperOptions, + popperRef: popperRefProp, + slotProps = {}, + slots = {}, + TransitionProps, + // @ts-ignore internal logic + ownerState: ownerStateProp, // prevent from spreading to DOM, it can come from the parent component e.g. Select. + ...other + } = props; + + const tooltipRef = React.useRef(null); + const ownRef = useForkRef(tooltipRef, forwardedRef); + + const popperRef = React.useRef(null); + const handlePopperRef = useForkRef(popperRef, popperRefProp); + const handlePopperRefRef = React.useRef(handlePopperRef); + useEnhancedEffect(() => { + handlePopperRefRef.current = handlePopperRef; + }, [handlePopperRef]); + React.useImperativeHandle(popperRefProp, () => popperRef.current!, []); + + const rtlPlacement = flipPlacement(initialPlacement, direction); + /** + * placement initialized from prop but can change during lifetime if modifiers.flip. + * modifiers.flip is essentially a flip for controlled/uncontrolled behavior + */ + const [placement, setPlacement] = React.useState(rtlPlacement); + const [resolvedAnchorElement, setResolvedAnchorElement] = React.useState< + HTMLElement | VirtualElement | null | undefined + >(resolveAnchorEl(anchorEl)); + + React.useEffect(() => { + if (popperRef.current) { + popperRef.current.forceUpdate(); + } + }); + + React.useEffect(() => { + if (anchorEl) { + setResolvedAnchorElement(resolveAnchorEl(anchorEl)); + } + }, [anchorEl]); + + useEnhancedEffect(() => { + if (!resolvedAnchorElement || !open) { + return undefined; + } + + const handlePopperUpdate = (data: State) => { + setPlacement(data.placement); + }; + + if (process.env.NODE_ENV !== 'production') { + if ( + resolvedAnchorElement && + isHTMLElement(resolvedAnchorElement) && + resolvedAnchorElement.nodeType === 1 + ) { + const box = resolvedAnchorElement.getBoundingClientRect(); + + if ( + process.env.NODE_ENV !== 'test' && + box.top === 0 && + box.left === 0 && + box.right === 0 && + box.bottom === 0 + ) { + console.warn( + [ + 'MUI: The `anchorEl` prop provided to the component is invalid.', + 'The anchor element should be part of the document layout.', + "Make sure the element is present in the document or that it's not display none.", + ].join('\n'), + ); + } + } + } + + let popperModifiers: Partial>[] = [ + { + name: 'preventOverflow', + options: { + altBoundary: disablePortal, + }, + }, + { + name: 'flip', + options: { + altBoundary: disablePortal, + }, + }, + { + name: 'onUpdate', + enabled: true, + phase: 'afterWrite', + fn: ({ state }) => { + handlePopperUpdate(state); + }, + }, + ]; + + if (modifiers != null) { + popperModifiers = popperModifiers.concat(modifiers); + } + if (popperOptions && popperOptions.modifiers != null) { + popperModifiers = popperModifiers.concat(popperOptions.modifiers); + } + + const popper = createPopper(resolvedAnchorElement, tooltipRef.current!, { + placement: rtlPlacement, + ...popperOptions, + modifiers: popperModifiers, + }); + + handlePopperRefRef.current!(popper); + + return () => { + popper.destroy(); + handlePopperRefRef.current!(null); + }; + }, [resolvedAnchorElement, disablePortal, modifiers, open, popperOptions, rtlPlacement]); + + const childProps: PopperChildrenProps = { placement: placement! }; + + if (TransitionProps !== null) { + childProps.TransitionProps = TransitionProps; + } + + const classes = useUtilityClasses(); + const Root = slots.root ?? 'div'; + + const rootProps: WithOptionalOwnerState = useSlotProps({ + elementType: Root, + externalSlotProps: slotProps.root, + externalForwardedProps: other, + additionalProps: { + role: 'tooltip', + ref: ownRef, + }, + ownerState: props, + className: classes.root, + }); + + return ( + {typeof children === 'function' ? children(childProps) : children} + ); +}) as PolymorphicComponent; + +/** + * Poppers rely on the 3rd party library [Popper.js](https://popper.js.org/docs/v2/) for positioning. + * + * Demos: + * + * - [Popper](https://mui.com/base-ui/react-popper/) + * + * API: + * + * - [Popper API](https://mui.com/base-ui/react-popper/components-api/#popper) + */ +const Popper = React.forwardRef(function Popper( + props: PopperProps, + forwardedRef: React.ForwardedRef, +) { + const { + anchorEl, + children, + container: containerProp, + direction = 'ltr', + disablePortal = false, + keepMounted = false, + modifiers, + open, + placement = 'bottom', + popperOptions = defaultPopperOptions, + popperRef, + style, + transition = false, + slotProps = {}, + slots = {}, + ...other + } = props; + + const [exited, setExited] = React.useState(true); + + const handleEnter = () => { + setExited(false); + }; + + const handleExited = () => { + setExited(true); + }; + + if (!keepMounted && !open && (!transition || exited)) { + return null; + } + + // If the container prop is provided, use that + // If the anchorEl prop is provided, use its parent body element as the container + // If neither are provided let the Modal take care of choosing the container + let container; + if (containerProp) { + container = containerProp; + } else if (anchorEl) { + const resolvedAnchorEl = resolveAnchorEl(anchorEl); + container = + resolvedAnchorEl && isHTMLElement(resolvedAnchorEl) + ? ownerDocument(resolvedAnchorEl).body + : ownerDocument(null).body; + } + const display = !open && keepMounted && (!transition || exited) ? 'none' : undefined; + const transitionProps: PopperTransitionProps | undefined = transition + ? { + in: open, + onEnter: handleEnter, + onExited: handleExited, + } + : undefined; + + return ( + + + {children} + + + ); +}) as PolymorphicComponent; + +Popper.propTypes /* remove-proptypes */ = { + // ┌────────────────────────────── Warning ──────────────────────────────┐ + // │ These PropTypes are generated from the TypeScript type definitions. │ + // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ + // └─────────────────────────────────────────────────────────────────────┘ + /** + * An HTML element, [virtualElement](https://popper.js.org/docs/v2/virtual-elements/), + * or a function that returns either. + * It's used to set the position of the popper. + * The return value will passed as the reference object of the Popper instance. + */ + anchorEl: chainPropTypes( + PropTypes.oneOfType([HTMLElementType, PropTypes.object, PropTypes.func]), + (props) => { + if (props.open) { + const resolvedAnchorEl = resolveAnchorEl(props.anchorEl); + + if ( + resolvedAnchorEl && + isHTMLElement(resolvedAnchorEl) && + resolvedAnchorEl.nodeType === 1 + ) { + const box = resolvedAnchorEl.getBoundingClientRect(); + + if ( + process.env.NODE_ENV !== 'test' && + box.top === 0 && + box.left === 0 && + box.right === 0 && + box.bottom === 0 + ) { + return new Error( + [ + 'MUI: The `anchorEl` prop provided to the component is invalid.', + 'The anchor element should be part of the document layout.', + "Make sure the element is present in the document or that it's not display none.", + ].join('\n'), + ); + } + } else if ( + !resolvedAnchorEl || + typeof resolvedAnchorEl.getBoundingClientRect !== 'function' || + (isVirtualElement(resolvedAnchorEl) && + resolvedAnchorEl.contextElement != null && + resolvedAnchorEl.contextElement.nodeType !== 1) + ) { + return new Error( + [ + 'MUI: The `anchorEl` prop provided to the component is invalid.', + 'It should be an HTML element instance or a virtualElement ', + '(https://popper.js.org/docs/v2/virtual-elements/).', + ].join('\n'), + ); + } + } + + return null; + }, + ), + /** + * Popper render function or node. + */ + children: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([ + PropTypes.node, + PropTypes.func, + ]), + /** + * An HTML element or function that returns one. + * The `container` will have the portal children appended to it. + * + * You can also provide a callback, which is called in a React layout effect. + * This lets you set the container from a ref, and also makes server-side rendering possible. + * + * By default, it uses the body of the top-level document object, + * so it's simply `document.body` most of the time. + */ + container: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([ + HTMLElementType, + PropTypes.func, + ]), + /** + * Direction of the text. + * @default 'ltr' + */ + direction: PropTypes.oneOf(['ltr', 'rtl']), + /** + * The `children` will be under the DOM hierarchy of the parent component. + * @default false + */ + disablePortal: PropTypes.bool, + /** + * Always keep the children in the DOM. + * This prop can be useful in SEO situation or + * when you want to maximize the responsiveness of the Popper. + * @default false + */ + keepMounted: PropTypes.bool, + /** + * Popper.js is based on a "plugin-like" architecture, + * most of its features are fully encapsulated "modifiers". + * + * A modifier is a function that is called each time Popper.js needs to + * compute the position of the popper. + * For this reason, modifiers should be very performant to avoid bottlenecks. + * To learn how to create a modifier, [read the modifiers documentation](https://popper.js.org/docs/v2/modifiers/). + */ + modifiers: PropTypes.arrayOf( + PropTypes.shape({ + data: PropTypes.object, + effect: PropTypes.func, + enabled: PropTypes.bool, + fn: PropTypes.func, + name: PropTypes.any, + options: PropTypes.object, + phase: PropTypes.oneOf([ + 'afterMain', + 'afterRead', + 'afterWrite', + 'beforeMain', + 'beforeRead', + 'beforeWrite', + 'main', + 'read', + 'write', + ]), + requires: PropTypes.arrayOf(PropTypes.string), + requiresIfExists: PropTypes.arrayOf(PropTypes.string), + }), + ), + /** + * If `true`, the component is shown. + */ + open: PropTypes.bool.isRequired, + /** + * Popper placement. + * @default 'bottom' + */ + placement: PropTypes.oneOf([ + 'auto-end', + 'auto-start', + 'auto', + 'bottom-end', + 'bottom-start', + 'bottom', + 'left-end', + 'left-start', + 'left', + 'right-end', + 'right-start', + 'right', + 'top-end', + 'top-start', + 'top', + ]), + /** + * Options provided to the [`Popper.js`](https://popper.js.org/docs/v2/constructors/#options) instance. + * @default {} + */ + popperOptions: PropTypes.shape({ + modifiers: PropTypes.array, + onFirstUpdate: PropTypes.func, + placement: PropTypes.oneOf([ + 'auto-end', + 'auto-start', + 'auto', + 'bottom-end', + 'bottom-start', + 'bottom', + 'left-end', + 'left-start', + 'left', + 'right-end', + 'right-start', + 'right', + 'top-end', + 'top-start', + 'top', + ]), + strategy: PropTypes.oneOf(['absolute', 'fixed']), + }), + /** + * A ref that points to the used popper instance. + */ + popperRef: refType, + /** + * The props used for each slot inside the Popper. + * @default {} + */ + slotProps: PropTypes.shape({ + root: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + }), + /** + * The components used for each slot inside the Popper. + * Either a string to use a HTML element or a component. + * @default {} + */ + slots: PropTypes.shape({ + root: PropTypes.elementType, + }), + /** + * Help supporting a react-transition-group/Transition component. + * @default false + */ + transition: PropTypes.bool, +} as any; + +export { Popper }; diff --git a/packages/mui-material/src/Popper/BasePopper.types.ts b/packages/mui-material/src/Popper/BasePopper.types.ts new file mode 100644 index 00000000000000..abeac3ec83c587 --- /dev/null +++ b/packages/mui-material/src/Popper/BasePopper.types.ts @@ -0,0 +1,154 @@ +import * as React from 'react'; +import { Instance, Options, OptionsGeneric, VirtualElement } from '@popperjs/core'; +import { PortalProps } from '../Portal'; +import { PolymorphicProps, SlotComponentProps } from '../utils'; + +export type PopperPlacementType = Options['placement']; + +export interface PopperRootSlotPropsOverrides {} + +export interface PopperTransitionProps { + in: boolean; + onEnter: () => void; + onExited: () => void; +} + +export interface PopperChildrenProps { + placement: PopperPlacementType; + TransitionProps?: PopperTransitionProps; +} + +export interface PopperOwnProps { + /** + * An HTML element, [virtualElement](https://popper.js.org/docs/v2/virtual-elements/), + * or a function that returns either. + * It's used to set the position of the popper. + * The return value will passed as the reference object of the Popper instance. + */ + anchorEl?: null | VirtualElement | HTMLElement | (() => HTMLElement) | (() => VirtualElement); + /** + * Popper render function or node. + */ + children?: React.ReactNode | ((props: PopperChildrenProps) => React.ReactNode); + /** + * An HTML element or function that returns one. + * The `container` will have the portal children appended to it. + * + * You can also provide a callback, which is called in a React layout effect. + * This lets you set the container from a ref, and also makes server-side rendering possible. + * + * By default, it uses the body of the top-level document object, + * so it's simply `document.body` most of the time. + */ + container?: PortalProps['container']; + /** + * Direction of the text. + * @default 'ltr' + */ + direction?: 'ltr' | 'rtl'; + /** + * The `children` will be under the DOM hierarchy of the parent component. + * @default false + */ + disablePortal?: PortalProps['disablePortal']; + /** + * Always keep the children in the DOM. + * This prop can be useful in SEO situation or + * when you want to maximize the responsiveness of the Popper. + * @default false + */ + keepMounted?: boolean; + /** + * Popper.js is based on a "plugin-like" architecture, + * most of its features are fully encapsulated "modifiers". + * + * A modifier is a function that is called each time Popper.js needs to + * compute the position of the popper. + * For this reason, modifiers should be very performant to avoid bottlenecks. + * To learn how to create a modifier, [read the modifiers documentation](https://popper.js.org/docs/v2/modifiers/). + */ + modifiers?: Options['modifiers']; + /** + * If `true`, the component is shown. + */ + open: boolean; + /** + * Popper placement. + * @default 'bottom' + */ + placement?: PopperPlacementType; + /** + * Options provided to the [`Popper.js`](https://popper.js.org/docs/v2/constructors/#options) instance. + * @default {} + */ + popperOptions?: Partial>; + /** + * A ref that points to the used popper instance. + */ + popperRef?: React.Ref; + /** + * The props used for each slot inside the Popper. + * @default {} + */ + slotProps?: { + root?: SlotComponentProps<'div', PopperRootSlotPropsOverrides, PopperOwnerState>; + }; + /** + * The components used for each slot inside the Popper. + * Either a string to use a HTML element or a component. + * @default {} + */ + slots?: PopperSlots; + /** + * Help supporting a react-transition-group/Transition component. + * @default false + */ + transition?: boolean; +} + +export interface PopperSlots { + /** + * The component that renders the root. + * @default 'div' + */ + root?: React.ElementType; +} + +export type PopperOwnerState = PopperOwnProps; + +export interface PopperTypeMap< + AdditionalProps = {}, + RootComponentType extends React.ElementType = 'div', +> { + props: PopperOwnProps & AdditionalProps; + defaultComponent: RootComponentType; +} + +export type PopperProps< + RootComponentType extends React.ElementType = PopperTypeMap['defaultComponent'], +> = PolymorphicProps, RootComponentType>; + +export type PopperTooltipOwnProps = Omit< + PopperOwnProps, + 'container' | 'keepMounted' | 'transition' +> & { + TransitionProps?: PopperTransitionProps; +}; + +export interface PopperTooltipTypeMap< + AdditionalProps = {}, + RootComponentType extends React.ElementType = 'div', +> { + props: PopperTooltipOwnProps & AdditionalProps; + defaultComponent: RootComponentType; +} + +export type PopperTooltipProps< + RootComponentType extends React.ElementType = PopperTooltipTypeMap['defaultComponent'], +> = PolymorphicProps, RootComponentType>; + +export interface PopperRootSlotProps { + className?: string; + ref: React.Ref; + ownerState: PopperOwnerState; +} diff --git a/packages/mui-material/src/Popper/popperClasses.ts b/packages/mui-material/src/Popper/popperClasses.ts new file mode 100644 index 00000000000000..31b81533a3bb7e --- /dev/null +++ b/packages/mui-material/src/Popper/popperClasses.ts @@ -0,0 +1,15 @@ +import generateUtilityClasses from '@mui/utils/generateUtilityClasses'; +import generateUtilityClass from '@mui/utils/generateUtilityClass'; + +export interface PopperClasses { + /** Class name applied to the root element. */ + root: string; +} + +export type PopperClassKey = keyof PopperClasses; + +export function getPopperUtilityClass(slot: string): string { + return generateUtilityClass('MuiPopper', slot); +} + +export const popperClasses: PopperClasses = generateUtilityClasses('MuiPopper', ['root']); diff --git a/packages/mui-material/src/Unstable_TrapFocus/FocusTrap.test.tsx b/packages/mui-material/src/Unstable_TrapFocus/FocusTrap.test.tsx new file mode 100644 index 00000000000000..0f98435a8a5e62 --- /dev/null +++ b/packages/mui-material/src/Unstable_TrapFocus/FocusTrap.test.tsx @@ -0,0 +1,410 @@ +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; +import { expect } from 'chai'; +import { act, createRenderer, screen } from '@mui/internal-test-utils'; +import FocusTrap from '@mui/material/Unstable_TrapFocus'; +import { Portal } from '@mui/base/Portal'; + +interface GenericProps { + [index: string]: any; +} + +describe('', () => { + const { clock, render } = createRenderer(); + + let initialFocus: HTMLElement | null = null; + + beforeEach(() => { + initialFocus = document.createElement('button'); + initialFocus.tabIndex = 0; + document.body.appendChild(initialFocus); + act(() => { + initialFocus!.focus(); + }); + }); + + afterEach(() => { + document.body.removeChild(initialFocus!); + }); + + it('should return focus to the root', () => { + const { getByTestId } = render( + +
      + +
      +
      , + // TODO: https://github.com/reactwg/react-18/discussions/18#discussioncomment-893076 + { strictEffects: false }, + ); + + expect(getByTestId('auto-focus')).toHaveFocus(); + + act(() => { + initialFocus!.focus(); + }); + expect(getByTestId('root')).toHaveFocus(); + }); + + it('should not return focus to the children when disableEnforceFocus is true', () => { + const { getByTestId } = render( + +
      + +
      +
      , + // TODO: https://github.com/reactwg/react-18/discussions/18#discussioncomment-893076s + { strictEffects: false }, + ); + + expect(getByTestId('auto-focus')).toHaveFocus(); + + act(() => { + initialFocus!.focus(); + }); + + expect(initialFocus).toHaveFocus(); + }); + + it('should focus first focusable child in portal', () => { + const { getByTestId } = render( + +
      + + + +
      +
      , + ); + + expect(getByTestId('auto-focus')).toHaveFocus(); + }); + + it('should warn if the root content is not focusable', () => { + const UnfocusableDialog = React.forwardRef((_, ref) =>
      ); + + expect(() => { + render( + + + , + ); + }).toErrorDev('MUI: The modal content node does not accept focus'); + }); + + it('should not attempt to focus nonexistent children', () => { + const EmptyDialog = React.forwardRef(() => null); + + render( + + + , + ); + }); + + it('should focus rootRef if no tabbable children are rendered', () => { + render( + +
      +
      Title
      +
      +
      , + ); + expect(screen.getByTestId('root')).toHaveFocus(); + }); + + it('does not steal focus from a portaled element if any prop but open changes', () => { + function Test(props: GenericProps) { + return ( + +
      + {ReactDOM.createPortal(, document.body)} +
      +
      + ); + } + const { setProps } = render(); + const portaledTextbox = screen.getByTestId('portal-input'); + act(() => { + portaledTextbox.focus(); + }); + + // sanity check + expect(portaledTextbox).toHaveFocus(); + + setProps({ disableAutoFocus: false }); + + expect(portaledTextbox).toHaveFocus(); + + setProps({ disableEnforceFocus: true }); + + expect(portaledTextbox).toHaveFocus(); + + setProps({ disableRestoreFocus: true }); + + expect(portaledTextbox).toHaveFocus(); + + // same behavior, just referential equality changes + setProps({ isEnabled: () => true }); + + expect(portaledTextbox).toHaveFocus(); + }); + + it('undesired: lazy root does not get autofocus', () => { + let mountDeferredComponent: React.DispatchWithoutAction; + const DeferredComponent = React.forwardRef( + function DeferredComponent(props, ref) { + const [mounted, setMounted] = React.useReducer(() => true, false); + + mountDeferredComponent = setMounted; + + if (mounted) { + return
      ; + } + return null; + }, + ); + render( + + + , + ); + + expect(initialFocus).toHaveFocus(); + + act(() => { + mountDeferredComponent(); + }); + + // desired + // expect(screen.getByTestId('deferred-component')).toHaveFocus(); + // undesired + expect(initialFocus).toHaveFocus(); + }); + + it('does not bounce focus around due to sync focus-restore + focus-contain', () => { + const eventLog: string[] = []; + function Test(props: GenericProps) { + return ( +
      eventLog.push('blur')}> + +
      + +
      +
      +
      + ); + } + const { setProps } = render(, { + // Strict Effects interferes with the premise of the test. + // It would trigger a focus restore (i.e. a blur event) + strictEffects: false, + }); + + // same behavior, just referential equality changes + setProps({ isEnabled: () => true }); + + expect(screen.getByTestId('root')).toHaveFocus(); + expect(eventLog).to.deep.equal([]); + }); + + it('does not focus if isEnabled returns false', () => { + function Test(props: GenericProps) { + return ( +
      + + +
      + +
      + ); + } + const { setProps, getByRole } = render(); + expect(screen.getByTestId('root')).toHaveFocus(); + + act(() => { + getByRole('textbox').focus(); + }); + expect(getByRole('textbox')).not.toHaveFocus(); + + setProps({ isEnabled: () => false }); + + act(() => { + getByRole('textbox').focus(); + }); + expect(getByRole('textbox')).toHaveFocus(); + }); + + it('restores focus when closed', () => { + function Test(props: GenericProps) { + return ( + +
      + +
      +
      + ); + } + const { setProps } = render(); + + setProps({ open: false }); + + expect(initialFocus).toHaveFocus(); + }); + + it('undesired: enabling restore-focus logic when closing has no effect', () => { + function Test(props: GenericProps) { + return ( + +
      + +
      +
      + ); + } + const { setProps } = render(); + + setProps({ open: false, disableRestoreFocus: false }); + + // undesired: should be expect(initialFocus).toHaveFocus(); + expect(screen.getByTestId('root')).toHaveFocus(); + }); + + it('undesired: setting `disableRestoreFocus` to false before closing has no effect', () => { + function Test(props: GenericProps) { + return ( + +
      + +
      +
      + ); + } + const { setProps } = render(); + + setProps({ disableRestoreFocus: false }); + setProps({ open: false }); + + // undesired: should be expect(initialFocus).toHaveFocus(); + expect(screen.getByTestId('root')).toHaveFocus(); + }); + + describe('interval', () => { + clock.withFakeTimers(); + + it('contains the focus if the active element is removed', () => { + function WithRemovableElement({ hideButton = false }) { + return ( + +
      + {!hideButton && ( + + )} +
      +
      + ); + } + + 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-material/src/Unstable_TrapFocus/FocusTrap.tsx b/packages/mui-material/src/Unstable_TrapFocus/FocusTrap.tsx new file mode 100644 index 00000000000000..3d00b5fc6f7bdc --- /dev/null +++ b/packages/mui-material/src/Unstable_TrapFocus/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, +} 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,
      ,