diff --git a/docs/data/material/pagesApi.js b/docs/data/material/pagesApi.js
index 7bb6fe6df78aa8..22ff392d72c282 100644
--- a/docs/data/material/pagesApi.js
+++ b/docs/data/material/pagesApi.js
@@ -27,6 +27,7 @@ module.exports = [
{ pathname: '/material-ui/api/checkbox' },
{ pathname: '/material-ui/api/chip' },
{ pathname: '/material-ui/api/circular-progress' },
+ { pathname: '/material-ui/api/click-away-listener' },
{ pathname: '/material-ui/api/collapse' },
{ pathname: '/material-ui/api/container' },
{ pathname: '/material-ui/api/css-baseline' },
@@ -76,12 +77,14 @@ module.exports = [
{ pathname: '/material-ui/api/mobile-stepper' },
{ pathname: '/material-ui/api/modal' },
{ pathname: '/material-ui/api/native-select' },
+ { pathname: '/material-ui/api/no-ssr' },
{ pathname: '/material-ui/api/outlined-input' },
{ pathname: '/material-ui/api/pagination' },
{ pathname: '/material-ui/api/pagination-item' },
{ pathname: '/material-ui/api/paper' },
{ pathname: '/material-ui/api/popover' },
{ pathname: '/material-ui/api/popper' },
+ { pathname: '/material-ui/api/portal' },
{ pathname: '/material-ui/api/radio' },
{ pathname: '/material-ui/api/radio-group' },
{ pathname: '/material-ui/api/rating' },
@@ -121,6 +124,7 @@ module.exports = [
{ pathname: '/material-ui/api/tab-panel' },
{ pathname: '/material-ui/api/tabs' },
{ pathname: '/material-ui/api/tab-scroll-button' },
+ { pathname: '/material-ui/api/textarea-autosize' },
{ pathname: '/material-ui/api/text-field' },
{ pathname: '/material-ui/api/timeline' },
{ pathname: '/material-ui/api/timeline-connector' },
diff --git a/docs/pages/material-ui/api/click-away-listener.js b/docs/pages/material-ui/api/click-away-listener.js
new file mode 100644
index 00000000000000..3776a973527b16
--- /dev/null
+++ b/docs/pages/material-ui/api/click-away-listener.js
@@ -0,0 +1,23 @@
+import * as React from 'react';
+import ApiPage from 'docs/src/modules/components/ApiPage';
+import mapApiPageTranslations from 'docs/src/modules/utils/mapApiPageTranslations';
+import jsonPageContent from './click-away-listener.json';
+
+export default function Page(props) {
+ const { descriptions, pageContent } = props;
+ return ;
+}
+
+Page.getInitialProps = () => {
+ const req = require.context(
+ 'docs/translations/api-docs/click-away-listener',
+ false,
+ /\.\/click-away-listener.*.json$/,
+ );
+ const descriptions = mapApiPageTranslations(req);
+
+ return {
+ descriptions,
+ pageContent: jsonPageContent,
+ };
+};
diff --git a/docs/pages/material-ui/api/click-away-listener.json b/docs/pages/material-ui/api/click-away-listener.json
new file mode 100644
index 00000000000000..a33e412e20cdac
--- /dev/null
+++ b/docs/pages/material-ui/api/click-away-listener.json
@@ -0,0 +1,34 @@
+{
+ "props": {
+ "children": { "type": { "name": "custom", "description": "element" }, "required": true },
+ "onClickAway": { "type": { "name": "func" }, "required": true },
+ "disableReactTree": { "type": { "name": "bool" }, "default": "false" },
+ "mouseEvent": {
+ "type": {
+ "name": "enum",
+ "description": "'onClick' | 'onMouseDown' | 'onMouseUp' | 'onPointerDown' | 'onPointerUp' | false"
+ },
+ "default": "'onClick'"
+ },
+ "touchEvent": {
+ "type": {
+ "name": "enum",
+ "description": "'onTouchEnd' | 'onTouchStart' | false"
+ },
+ "default": "'onTouchEnd'"
+ }
+ },
+ "name": "ClickAwayListener",
+ "imports": [
+ "import ClickAwayListener from '@mui/material/ClickAwayListener';",
+ "import { ClickAwayListener } from '@mui/material';"
+ ],
+ "classes": [],
+ "spread": false,
+ "themeDefaultProps": null,
+ "muiName": "MuiClickAwayListener",
+ "filename": "/packages/mui-material/src/ClickAwayListener/ClickAwayListener.tsx",
+ "inheritance": null,
+ "demos": "
",
+ "cssComponent": false
+}
diff --git a/docs/pages/material-ui/api/no-ssr.js b/docs/pages/material-ui/api/no-ssr.js
new file mode 100644
index 00000000000000..03c084bbcf6a12
--- /dev/null
+++ b/docs/pages/material-ui/api/no-ssr.js
@@ -0,0 +1,19 @@
+import * as React from 'react';
+import ApiPage from 'docs/src/modules/components/ApiPage';
+import mapApiPageTranslations from 'docs/src/modules/utils/mapApiPageTranslations';
+import jsonPageContent from './no-ssr.json';
+
+export default function Page(props) {
+ const { descriptions, pageContent } = props;
+ return ;
+}
+
+Page.getInitialProps = () => {
+ const req = require.context('docs/translations/api-docs/no-ssr', false, /\.\/no-ssr.*.json$/);
+ const descriptions = mapApiPageTranslations(req);
+
+ return {
+ descriptions,
+ pageContent: jsonPageContent,
+ };
+};
diff --git a/docs/pages/material-ui/api/no-ssr.json b/docs/pages/material-ui/api/no-ssr.json
new file mode 100644
index 00000000000000..464dc7136b18ca
--- /dev/null
+++ b/docs/pages/material-ui/api/no-ssr.json
@@ -0,0 +1,17 @@
+{
+ "props": {
+ "children": { "type": { "name": "node" } },
+ "defer": { "type": { "name": "bool" }, "default": "false" },
+ "fallback": { "type": { "name": "node" }, "default": "null" }
+ },
+ "name": "NoSsr",
+ "imports": ["import NoSsr from '@mui/material/NoSsr';", "import { NoSsr } from '@mui/material';"],
+ "classes": [],
+ "spread": false,
+ "themeDefaultProps": null,
+ "muiName": "MuiNoSsr",
+ "filename": "/packages/mui-material/src/NoSsr/NoSsr.tsx",
+ "inheritance": null,
+ "demos": "",
+ "cssComponent": false
+}
diff --git a/docs/pages/material-ui/api/popper.json b/docs/pages/material-ui/api/popper.json
index 06f2880cdc5b9e..14fedb4f023556 100644
--- a/docs/pages/material-ui/api/popper.json
+++ b/docs/pages/material-ui/api/popper.json
@@ -63,7 +63,14 @@
"import Popper from '@mui/material/Popper';",
"import { Popper } from '@mui/material';"
],
- "classes": [],
+ "classes": [
+ {
+ "key": "root",
+ "className": "MuiPopper-root",
+ "description": "Class name applied to the root element.",
+ "isGlobal": false
+ }
+ ],
"spread": true,
"themeDefaultProps": false,
"muiName": "MuiPopper",
diff --git a/docs/pages/material-ui/api/portal.js b/docs/pages/material-ui/api/portal.js
new file mode 100644
index 00000000000000..e53de2d6983e86
--- /dev/null
+++ b/docs/pages/material-ui/api/portal.js
@@ -0,0 +1,19 @@
+import * as React from 'react';
+import ApiPage from 'docs/src/modules/components/ApiPage';
+import mapApiPageTranslations from 'docs/src/modules/utils/mapApiPageTranslations';
+import jsonPageContent from './portal.json';
+
+export default function Page(props) {
+ const { descriptions, pageContent } = props;
+ return ;
+}
+
+Page.getInitialProps = () => {
+ const req = require.context('docs/translations/api-docs/portal', false, /\.\/portal.*.json$/);
+ const descriptions = mapApiPageTranslations(req);
+
+ return {
+ descriptions,
+ pageContent: jsonPageContent,
+ };
+};
diff --git a/docs/pages/material-ui/api/portal.json b/docs/pages/material-ui/api/portal.json
new file mode 100644
index 00000000000000..1079011f1f82f4
--- /dev/null
+++ b/docs/pages/material-ui/api/portal.json
@@ -0,0 +1,20 @@
+{
+ "props": {
+ "children": { "type": { "name": "node" } },
+ "container": { "type": { "name": "union", "description": "HTML element | func" } },
+ "disablePortal": { "type": { "name": "bool" }, "default": "false" }
+ },
+ "name": "Portal",
+ "imports": [
+ "import Portal from '@mui/material/Portal';",
+ "import { Portal } from '@mui/material';"
+ ],
+ "classes": [],
+ "spread": false,
+ "themeDefaultProps": null,
+ "muiName": "MuiPortal",
+ "filename": "/packages/mui-material/src/Portal/Portal.tsx",
+ "inheritance": null,
+ "demos": "",
+ "cssComponent": false
+}
diff --git a/docs/pages/material-ui/api/textarea-autosize.js b/docs/pages/material-ui/api/textarea-autosize.js
new file mode 100644
index 00000000000000..4a8184ae71999b
--- /dev/null
+++ b/docs/pages/material-ui/api/textarea-autosize.js
@@ -0,0 +1,23 @@
+import * as React from 'react';
+import ApiPage from 'docs/src/modules/components/ApiPage';
+import mapApiPageTranslations from 'docs/src/modules/utils/mapApiPageTranslations';
+import jsonPageContent from './textarea-autosize.json';
+
+export default function Page(props) {
+ const { descriptions, pageContent } = props;
+ return ;
+}
+
+Page.getInitialProps = () => {
+ const req = require.context(
+ 'docs/translations/api-docs/textarea-autosize',
+ false,
+ /\.\/textarea-autosize.*.json$/,
+ );
+ const descriptions = mapApiPageTranslations(req);
+
+ return {
+ descriptions,
+ pageContent: jsonPageContent,
+ };
+};
diff --git a/docs/pages/material-ui/api/textarea-autosize.json b/docs/pages/material-ui/api/textarea-autosize.json
new file mode 100644
index 00000000000000..06a5fbb2bf09b3
--- /dev/null
+++ b/docs/pages/material-ui/api/textarea-autosize.json
@@ -0,0 +1,22 @@
+{
+ "props": {
+ "maxRows": { "type": { "name": "union", "description": "number | string" } },
+ "minRows": {
+ "type": { "name": "union", "description": "number | string" },
+ "default": "1"
+ }
+ },
+ "name": "TextareaAutosize",
+ "imports": [
+ "import TextareaAutosize from '@mui/material/TextareaAutosize';",
+ "import { TextareaAutosize } from '@mui/material';"
+ ],
+ "classes": [],
+ "spread": true,
+ "themeDefaultProps": null,
+ "muiName": "MuiTextareaAutosize",
+ "filename": "/packages/mui-material/src/TextareaAutosize/TextareaAutosize.tsx",
+ "inheritance": null,
+ "demos": "",
+ "cssComponent": false
+}
diff --git a/docs/translations/api-docs/click-away-listener/click-away-listener.json b/docs/translations/api-docs/click-away-listener/click-away-listener.json
new file mode 100644
index 00000000000000..28136c93c705af
--- /dev/null
+++ b/docs/translations/api-docs/click-away-listener/click-away-listener.json
@@ -0,0 +1,19 @@
+{
+ "componentDescription": "Listen for click events that occur somewhere in the document, outside of the element itself.\nFor instance, if you need to hide a menu when people click anywhere else on your page.",
+ "propDescriptions": {
+ "children": { "description": "The wrapped element.", "requiresRef": true },
+ "disableReactTree": {
+ "description": "If true
, the React tree is ignored and only the DOM tree is considered. This prop changes how portaled elements are handled."
+ },
+ "mouseEvent": {
+ "description": "The mouse event to listen to. You can disable the listener by providing false
."
+ },
+ "onClickAway": {
+ "description": "Callback fired when a "click away" event is detected."
+ },
+ "touchEvent": {
+ "description": "The touch event to listen to. You can disable the listener by providing false
."
+ }
+ },
+ "classDescriptions": {}
+}
diff --git a/docs/translations/api-docs/no-ssr/no-ssr.json b/docs/translations/api-docs/no-ssr/no-ssr.json
new file mode 100644
index 00000000000000..ff903d12a20b33
--- /dev/null
+++ b/docs/translations/api-docs/no-ssr/no-ssr.json
@@ -0,0 +1,11 @@
+{
+ "componentDescription": "NoSsr purposely removes components from the subject of Server Side Rendering (SSR).\n\nThis component can be useful in a variety of situations:\n\n* Escape hatch for broken dependencies not supporting SSR.\n* Improve the time-to-first paint on the client by only rendering above the fold.\n* Reduce the rendering time on the server.\n* Under too heavy server load, you can turn on service degradation.",
+ "propDescriptions": {
+ "children": { "description": "You can wrap a node." },
+ "defer": {
+ "description": "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."
+ },
+ "fallback": { "description": "The fallback content to display." }
+ },
+ "classDescriptions": {}
+}
diff --git a/docs/translations/api-docs/popper/popper.json b/docs/translations/api-docs/popper/popper.json
index 178879e0bd5352..23b39e61d6fa7a 100644
--- a/docs/translations/api-docs/popper/popper.json
+++ b/docs/translations/api-docs/popper/popper.json
@@ -41,5 +41,5 @@
"description": "Help supporting a react-transition-group/Transition component."
}
},
- "classDescriptions": {}
+ "classDescriptions": { "root": { "description": "Class name applied to the root element." } }
}
diff --git a/docs/translations/api-docs/portal/portal.json b/docs/translations/api-docs/portal/portal.json
new file mode 100644
index 00000000000000..d7fb72bca569c9
--- /dev/null
+++ b/docs/translations/api-docs/portal/portal.json
@@ -0,0 +1,13 @@
+{
+ "componentDescription": "Portals provide a first-class way to render children into a DOM node\nthat exists outside the DOM hierarchy of the parent component.",
+ "propDescriptions": {
+ "children": { "description": "The children to render into the container
." },
+ "container": {
+ "description": "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."
+ },
+ "disablePortal": {
+ "description": "The children
will be under the DOM hierarchy of the parent component."
+ }
+ },
+ "classDescriptions": {}
+}
diff --git a/docs/translations/api-docs/textarea-autosize/textarea-autosize.json b/docs/translations/api-docs/textarea-autosize/textarea-autosize.json
new file mode 100644
index 00000000000000..ee91d0dd6d70c6
--- /dev/null
+++ b/docs/translations/api-docs/textarea-autosize/textarea-autosize.json
@@ -0,0 +1,8 @@
+{
+ "componentDescription": "",
+ "propDescriptions": {
+ "maxRows": { "description": "Maximum number of rows to display." },
+ "minRows": { "description": "Minimum number of rows to display." }
+ },
+ "classDescriptions": {}
+}
diff --git a/packages/api-docs-builder-core/baseUi/projectSettings.ts b/packages/api-docs-builder-core/baseUi/projectSettings.ts
index b9b64d42adc3c4..6e7df498b54278 100644
--- a/packages/api-docs-builder-core/baseUi/projectSettings.ts
+++ b/packages/api-docs-builder-core/baseUi/projectSettings.ts
@@ -27,6 +27,9 @@ export const projectSettings: ProjectSettings = {
getHookInfo: getBaseUiHookInfo,
translationLanguages: LANGUAGES,
skipComponent: () => false,
+ skipHook: (filename) => {
+ return filename.match(/(useSlotProps)/) !== null;
+ },
onCompleted: async () => {
await generateBaseUIApiPages();
},
diff --git a/packages/api-docs-builder/ProjectSettings.ts b/packages/api-docs-builder/ProjectSettings.ts
index 2ba937f0a4e9b2..8baa6193c75912 100644
--- a/packages/api-docs-builder/ProjectSettings.ts
+++ b/packages/api-docs-builder/ProjectSettings.ts
@@ -56,6 +56,10 @@ export interface ProjectSettings {
* Fuction called to detemine whether to skip the generation of a particular component's API docs
*/
skipComponent: (filename: string) => boolean;
+ /**
+ * Fuction called to detemine whether to skip the generation of a particular hook's API docs
+ */
+ skipHook?: (filename: string) => boolean;
/**
* Determine is the component definition should be updated.
*/
diff --git a/packages/api-docs-builder/buildApi.ts b/packages/api-docs-builder/buildApi.ts
index 886b04f2e02f07..29bec459529bc5 100644
--- a/packages/api-docs-builder/buildApi.ts
+++ b/packages/api-docs-builder/buildApi.ts
@@ -135,6 +135,9 @@ async function buildSingleProject(
);
const projectHooks = findHooks(path.join(project.rootPath, 'src')).filter((hook) => {
+ if (projectSettings.skipHook?.(hook.filename)) {
+ return false;
+ }
if (grep === null) {
return true;
}
diff --git a/packages/mui-base/src/utils/appendOwnerState.ts b/packages/mui-base/src/utils/appendOwnerState.ts
index 0ef5e0c47a4f60..9c4b778bef1add 100644
--- a/packages/mui-base/src/utils/appendOwnerState.ts
+++ b/packages/mui-base/src/utils/appendOwnerState.ts
@@ -1,51 +1,3 @@
-import * as React from 'react';
-import { Simplify } from '@mui/types';
-import { isHostComponent } from './isHostComponent';
+export { default as appendOwnerState } from '@mui/utils/appendOwnerState';
-/**
- * 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 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;
-}
+export type { AppendOwnerStateReturnType } from '@mui/utils/appendOwnerState';
diff --git a/packages/mui-base/src/utils/extractEventHandlers.ts b/packages/mui-base/src/utils/extractEventHandlers.ts
index e3998c358e0240..9cead2050dc828 100644
--- a/packages/mui-base/src/utils/extractEventHandlers.ts
+++ b/packages/mui-base/src/utils/extractEventHandlers.ts
@@ -1,30 +1 @@
-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;
-}
+export { default as extractEventHandlers } from '@mui/utils/extractEventHandlers';
diff --git a/packages/mui-base/src/utils/types.ts b/packages/mui-base/src/utils/types.ts
index 4d2a96ce8f99e1..3a1883cec220b7 100644
--- a/packages/mui-base/src/utils/types.ts
+++ b/packages/mui-base/src/utils/types.ts
@@ -1,27 +1,6 @@
-import * as React from 'react';
-
-export type EventHandlers = Record>;
-
-export type WithOptionalOwnerState = Omit<
- Props,
- 'ownerState'
-> &
- Partial>;
-
-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 {
+ EventHandlers,
+ WithOptionalOwnerState,
+ SlotComponentProps,
+ SlotComponentPropsWithSlotState,
+} from '@mui/utils';
diff --git a/packages/mui-base/src/utils/useSlotProps.ts b/packages/mui-base/src/utils/useSlotProps.ts
index 10e40630768283..3d11566e23eb35 100644
--- a/packages/mui-base/src/utils/useSlotProps.ts
+++ b/packages/mui-base/src/utils/useSlotProps.ts
@@ -1,113 +1,4 @@
'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 { default as useSlotProps } from '@mui/utils/useSlotProps';
-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;
-}
+export type { UseSlotPropsParameters, UseSlotPropsResult } from '@mui/utils/useSlotProps';
diff --git a/packages/mui-material/package.json b/packages/mui-material/package.json
index baf55b9e9e5175..bb8886efa10bcb 100644
--- a/packages/mui-material/package.json
+++ b/packages/mui-material/package.json
@@ -42,8 +42,7 @@
"typescript:module-augmentation": "node scripts/testModuleAugmentation.js"
},
"dependencies": {
- "@babel/runtime": "^7.23.9",
- "@mui/base": "workspace:*",
+ "@babel/runtime": "^7.24.7",
"@mui/core-downloads-tracker": "workspace:^",
"@mui/system": "workspace:^",
"@mui/types": "workspace:^",
diff --git a/packages/mui-material/src/Autocomplete/Autocomplete.d.ts b/packages/mui-material/src/Autocomplete/Autocomplete.d.ts
index d196d142531e42..8d76e70b68abc5 100644
--- a/packages/mui-material/src/Autocomplete/Autocomplete.d.ts
+++ b/packages/mui-material/src/Autocomplete/Autocomplete.d.ts
@@ -1,8 +1,11 @@
import * as React from 'react';
import { SxProps } from '@mui/system';
import { OverridableStringUnion } from '@mui/types';
-import {
- useAutocomplete,
+import { IconButtonProps, InternalStandardProps as StandardProps, Theme } from '@mui/material';
+import { ChipProps, ChipTypeMap } from '@mui/material/Chip';
+import { PaperProps } from '@mui/material/Paper';
+import { PopperProps } from '@mui/material/Popper';
+import useAutocomplete, {
AutocompleteChangeDetails,
AutocompleteChangeReason,
AutocompleteCloseReason,
@@ -11,11 +14,7 @@ import {
createFilterOptions,
UseAutocompleteProps,
AutocompleteFreeSoloValueMapping,
-} from '@mui/base';
-import { IconButtonProps, InternalStandardProps as StandardProps, Theme } from '@mui/material';
-import { ChipProps, ChipTypeMap } from '@mui/material/Chip';
-import { PaperProps } from '@mui/material/Paper';
-import { PopperProps } from '@mui/material/Popper';
+} from '../useAutocomplete';
import { AutocompleteClasses } from './autocompleteClasses';
export {
diff --git a/packages/mui-material/src/Autocomplete/Autocomplete.js b/packages/mui-material/src/Autocomplete/Autocomplete.js
index de6e7ba5844f6c..097ded86f4f109 100644
--- a/packages/mui-material/src/Autocomplete/Autocomplete.js
+++ b/packages/mui-material/src/Autocomplete/Autocomplete.js
@@ -4,9 +4,9 @@ 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 composeClasses from '@mui/utils/composeClasses';
import { alpha } from '@mui/system/colorManipulator';
+import useAutocomplete, { createFilterOptions } from '../useAutocomplete';
import Popper from '../Popper';
import ListSubheader from '../ListSubheader';
import Paper from '../Paper';
diff --git a/packages/mui-material/src/Badge/Badge.d.ts b/packages/mui-material/src/Badge/Badge.d.ts
index 5ca85710f17459..a50276479dc92d 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 4eff3401d1ff78..a01078205b6b1d 100644
--- a/packages/mui-material/src/Badge/Badge.js
+++ b/packages/mui-material/src/Badge/Badge.js
@@ -4,8 +4,8 @@ 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 { useSlotProps } from '@mui/base/utils';
+import useSlotProps from '@mui/utils/useSlotProps';
+import useBadge from './useBadge';
import { styled } from '../zero-styled';
import { useDefaultProps } from '../DefaultPropsProvider';
import capitalize from '../utils/capitalize';
diff --git a/packages/mui-material/src/Badge/useBadge.ts b/packages/mui-material/src/Badge/useBadge.ts
new file mode 100644
index 00000000000000..214f281d084c35
--- /dev/null
+++ b/packages/mui-material/src/Badge/useBadge.ts
@@ -0,0 +1,48 @@
+'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)
+ */
+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,
+ };
+}
+
+export default useBadge;
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/Breadcrumbs/Breadcrumbs.d.ts b/packages/mui-material/src/Breadcrumbs/Breadcrumbs.d.ts
index af7fb0280752cc..a7cce24589befd 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 d18306a79339a0..a398e73d9fd778 100644
--- a/packages/mui-material/src/Breadcrumbs/Breadcrumbs.js
+++ b/packages/mui-material/src/Breadcrumbs/Breadcrumbs.js
@@ -4,9 +4,9 @@ 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 styled from '../styles/styled';
+import useSlotProps from '@mui/utils/useSlotProps';
+import { styled } from '../zero-styled';
import { useDefaultProps } from '../DefaultPropsProvider';
import Typography from '../Typography';
import BreadcrumbCollapsed from './BreadcrumbCollapsed';
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..e0c4c3730a4a82
--- /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/material/Portal';
+import ClickAwayListener from '@mui/material/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(
+
+
+ ,
+ );
+
+ 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(
+
+
+ ,
+ );
+
+ 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 (
+
+ setOpen(true) }} />
+ {open &&
+ ReactDOM.createPortal(
+ setOpen(false)}>
+
+ ,
+ // Needs to be an element between the react root we render into and the element where CAL attaches its native listener (now: `document`).
+ document.body,
+ )}
+
+ );
+ }
+ render( );
+
+ fireDiscreteEvent.click(screen.getByTestId('trigger'));
+
+ expect(screen.getByTestId('child')).not.to.equal(null);
+ });
+ });
+
+ it('should be called if an element is interleaved between mousedown and mouseup', () => {
+ /**
+ * @param {Element} element
+ * @returns {Element[]}
+ */
+ function ancestorElements(element) {
+ const ancestors = [];
+ let ancestor = element;
+ while (ancestor !== null) {
+ ancestors.unshift(ancestor);
+ ancestor = ancestor.parentElement;
+ }
+ return ancestors;
+ }
+
+ /**
+ * @param {Element} elementA
+ * @param {Element} elementB
+ * @returns {Element}
+ */
+ function findNearestCommonAncestor(elementA, elementB) {
+ const ancestorsA = ancestorElements(elementA);
+ const ancestorsB = ancestorElements(elementB);
+
+ if (ancestorsA[0] !== ancestorsB[0]) {
+ throw new Error('A and B share no common ancestor');
+ }
+
+ for (let index = 1; index < ancestorsA.length; index += 1) {
+ if (ancestorsA[index] !== ancestorsB[index]) {
+ return ancestorsA[index - 1];
+ }
+ }
+
+ throw new Error('Unreachable reached. This is a bug in findNearestCommonAncestor');
+ }
+
+ const onClickAway = spy();
+ function ClickAwayListenerMouseDownPortal() {
+ const [open, toggleOpen] = React.useReducer((flag) => !flag, false);
+
+ return (
+
+
+ {open &&
+ // interleave an element during mousedown so that the following mouseup would not be targetted at the mousedown target.
+ // This results in the click event being targetted at the nearest common ancestor.
+ ReactDOM.createPortal(
+
Portaled Div
,
+ document.body,
+ )}
+
+
+ );
+ }
+ render( );
+ const mouseDownTarget = screen.getByTestId('trigger');
+
+ fireDiscreteEvent.mouseDown(mouseDownTarget);
+ const mouseUpTarget = screen.getByTestId('interleaved-element');
+ // https://w3c.github.io/uievents/#events-mouseevent-event-order
+ const clickTarget = findNearestCommonAncestor(mouseDownTarget, mouseUpTarget);
+ fireDiscreteEvent.mouseUp(mouseUpTarget);
+ fireDiscreteEvent.click(clickTarget);
+
+ expect(onClickAway.callCount).to.equal(1);
+ });
+ });
+
+ describe('prop: mouseEvent', () => {
+ it('should not call `props.onClickAway` when `props.mouseEvent` is `false`', () => {
+ const handleClickAway = spy();
+ render(
+
+
+ ,
+ );
+ fireEvent.click(document.body);
+ expect(handleClickAway.callCount).to.equal(0);
+ });
+
+ it('should call `props.onClickAway` when mouse down is triggered', () => {
+ const handleClickAway = spy();
+ render(
+
+
+ ,
+ );
+ fireEvent.mouseUp(document.body);
+ expect(handleClickAway.callCount).to.equal(0);
+ fireEvent.mouseDown(document.body);
+ expect(handleClickAway.callCount).to.equal(1);
+ expect(handleClickAway.args[0].length).to.equal(1);
+ });
+
+ it('should call `props.onClickAway` when mouse up is triggered', () => {
+ const handleClickAway = spy();
+ render(
+
+
+ ,
+ );
+ fireEvent.mouseDown(document.body);
+ expect(handleClickAway.callCount).to.equal(0);
+ fireEvent.mouseUp(document.body);
+ expect(handleClickAway.callCount).to.equal(1);
+ expect(handleClickAway.args[0].length).to.equal(1);
+ });
+
+ it('should call `props.onClickAway` when pointer down is triggered', () => {
+ const handleClickAway = spy();
+ render(
+
+
+ ,
+ );
+ fireEvent.pointerUp(document.body);
+ expect(handleClickAway.callCount).to.equal(0);
+ fireEvent.pointerDown(document.body);
+ expect(handleClickAway.callCount).to.equal(1);
+ expect(handleClickAway.args[0].length).to.equal(1);
+ });
+
+ it('should call `props.onClickAway` when pointer up is triggered', () => {
+ const handleClickAway = spy();
+ render(
+
+
+ ,
+ );
+ fireEvent.pointerDown(document.body);
+ expect(handleClickAway.callCount).to.equal(0);
+ fireEvent.pointerUp(document.body);
+ expect(handleClickAway.callCount).to.equal(1);
+ expect(handleClickAway.args[0].length).to.equal(1);
+ });
+ });
+
+ describe('prop: touchEvent', () => {
+ it('should not call `props.onClickAway` when `props.touchEvent` is `false`', () => {
+ const handleClickAway = spy();
+ render(
+
+
+ ,
+ );
+ fireEvent.touchEnd(document.body);
+ expect(handleClickAway.callCount).to.equal(0);
+ });
+
+ it('should call `props.onClickAway` when the appropriate touch event is triggered', () => {
+ const handleClickAway = spy();
+ render(
+
+
+ ,
+ );
+ fireEvent.touchEnd(document.body);
+ expect(handleClickAway.callCount).to.equal(0);
+ fireEvent.touchStart(document.body);
+ expect(handleClickAway.callCount).to.equal(1);
+ expect(handleClickAway.args[0].length).to.equal(1);
+ });
+
+ it('should ignore `touchend` when preceded by `touchmove` event', () => {
+ const handleClickAway = spy();
+ render(
+
+
+ ,
+ );
+
+ fireEvent.touchStart(document.body);
+ fireEvent.touchMove(document.body);
+ fireEvent.touchEnd(document.body);
+ expect(handleClickAway.callCount).to.equal(0);
+
+ fireEvent.touchEnd(document.body);
+ expect(handleClickAway.callCount).to.equal(1);
+ expect(handleClickAway.args[0].length).to.equal(1);
+ });
+ });
+
+ it('should handle null child', () => {
+ const Child = React.forwardRef(() => null);
+ const handleClickAway = spy();
+ render(
+
+
+ ,
+ );
+ fireEvent.click(document.body);
+ expect(handleClickAway.callCount).to.equal(0);
+ });
+
+ [
+ ['onClick', false],
+ ['onClick', true],
+ ['onClickCapture', false],
+ ['onClickCapture', true],
+ ].forEach(([eventName, disableReactTree]) => {
+ it(`when 'disableRectTree=${disableReactTree}' ${eventName} triggers onClickAway if an outside target is removed`, function test() {
+ if (!new Event('click').composedPath) {
+ this.skip();
+ }
+
+ const handleClickAway = spy();
+ function Test() {
+ const [buttonShown, hideButton] = React.useReducer(() => false, true);
+
+ return (
+
+ {buttonShown && }
+
+
+
+
+ );
+ }
+ render( );
+
+ act(() => {
+ screen.getByRole('button').click();
+ });
+
+ expect(handleClickAway.callCount).to.equal(1);
+ });
+
+ it(`when 'disableRectTree=${disableReactTree}' ${eventName} does not trigger onClickAway if an inside target is removed`, function test() {
+ if (!new Event('click').composedPath) {
+ this.skip();
+ }
+
+ const handleClickAway = spy();
+
+ function Test() {
+ const [buttonShown, hideButton] = React.useReducer(() => false, true);
+
+ return (
+
+ {buttonShown && }
+
+ );
+ }
+ 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..ca36844eaf16a5
--- /dev/null
+++ b/packages/mui-material/src/ClickAwayListener/ClickAwayListener.tsx
@@ -0,0 +1,262 @@
+'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://next.mui.com/material-ui/react-click-away-listener/)
+ * - [Menu](https://next.mui.com/material-ui/react-menu/)
+ *
+ * API:
+ *
+ * - [ClickAwayListener API](https://next.mui.com/material-ui/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/InputBase/InputBase.js b/packages/mui-material/src/InputBase/InputBase.js
index 7df98057ed3bef..3273342f97effd 100644
--- a/packages/mui-material/src/InputBase/InputBase.js
+++ b/packages/mui-material/src/InputBase/InputBase.js
@@ -5,9 +5,9 @@ import clsx from 'clsx';
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 TextareaAutosize from '../TextareaAutosize';
+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 5c6eea1aa16378..bf100de4dfff10 100644
--- a/packages/mui-material/src/ListItem/ListItem.js
+++ b/packages/mui-material/src/ListItem/ListItem.js
@@ -2,12 +2,12 @@
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 styled from '../styles/styled';
+import isHostComponent from '../utils/isHostComponent';
+import { styled } from '../zero-styled';
import { useDefaultProps } from '../DefaultPropsProvider';
import ButtonBase from '../ButtonBase';
import isMuiElement from '../utils/isMuiElement';
diff --git a/packages/mui-material/src/Menu/Menu.js b/packages/mui-material/src/Menu/Menu.js
index 60af5989cb2eb7..c0650ed1bc54f9 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 '@mui/utils/useSlotProps';
import MenuList from '../MenuList';
import Popover, { PopoverPaper } from '../Popover';
import styled, { rootShouldForwardProp } from '../styles/styled';
diff --git a/packages/mui-material/src/Modal/Modal.d.ts b/packages/mui-material/src/Modal/Modal.d.ts
index a9ad08c406c944..71eda489f68f0b 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/Modal/Modal.js b/packages/mui-material/src/Modal/Modal.js
index 166c3b63494770..22b94c5d5124c4 100644
--- a/packages/mui-material/src/Modal/Modal.js
+++ b/packages/mui-material/src/Modal/Modal.js
@@ -4,14 +4,13 @@ import PropTypes from 'prop-types';
import clsx from 'clsx';
import HTMLElementType from '@mui/utils/HTMLElementType';
import elementAcceptingRef from '@mui/utils/elementAcceptingRef';
-import { useSlotProps } from '@mui/base/utils';
-import { unstable_useModal as useModal } from '@mui/base/unstable_useModal';
import composeClasses from '@mui/utils/composeClasses';
import FocusTrap from '../Unstable_TrapFocus';
import Portal from '../Portal';
import styled from '../styles/styled';
import { useDefaultProps } from '../DefaultPropsProvider';
import Backdrop from '../Backdrop';
+import useModal from './useModal';
import { getModalUtilityClass } from './modalClasses';
const useUtilityClasses = (ownerState) => {
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..eb7924d7eab790
--- /dev/null
+++ b/packages/mui-material/src/Modal/useModal.ts
@@ -0,0 +1,243 @@
+'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 extractEventHandlers from '@mui/utils/extractEventHandlers';
+import { EventHandlers } from '../utils/types';
+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)
+ */
+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,
+ };
+}
+
+export default useModal;
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/NoSsr/NoSsr.test.tsx b/packages/mui-material/src/NoSsr/NoSsr.test.tsx
new file mode 100644
index 00000000000000..5f84b87f8ff1d6
--- /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..9bf17444706241
--- /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://next.mui.com/material-ui/react-no-ssr/)
+ *
+ * API:
+ *
+ * - [NoSsr API](https://next.mui.com/material-ui/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 default 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..533034468836fe 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 { 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..7f31330f820782 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 { default } from './NoSsr';
diff --git a/packages/mui-material/src/Popover/Popover.js b/packages/mui-material/src/Popover/Popover.js
index f5c9eb284a4f63..3a71ed5a84d35c 100644
--- a/packages/mui-material/src/Popover/Popover.js
+++ b/packages/mui-material/src/Popover/Popover.js
@@ -2,14 +2,14 @@
import * as React from 'react';
import PropTypes from 'prop-types';
import clsx from 'clsx';
-import { useSlotProps, 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 styled from '../styles/styled';
+import isHostComponent from '../utils/isHostComponent';
+import { styled } from '../zero-styled';
import { useDefaultProps } from '../DefaultPropsProvider';
import debounce from '../utils/debounce';
import ownerDocument from '../utils/ownerDocument';
diff --git a/packages/mui-material/src/Popper/BasePopper.tsx b/packages/mui-material/src/Popper/BasePopper.tsx
new file mode 100644
index 00000000000000..235874aab7a8e9
--- /dev/null
+++ b/packages/mui-material/src/Popper/BasePopper.tsx
@@ -0,0 +1,539 @@
+'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 '@mui/utils/composeClasses';
+import useSlotProps from '@mui/utils/useSlotProps';
+import Portal from '../Portal';
+import { getPopperUtilityClass } from './popperClasses';
+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 = (ownerState: any) => {
+ const { classes } = ownerState;
+ const slots = {
+ root: ['root'],
+ };
+
+ return composeClasses(slots, getPopperUtilityClass, classes);
+};
+
+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(props);
+ 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;
+
+/**
+ * @ignore - internal component.
+ */
+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 default 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..b7891291d54e27
--- /dev/null
+++ b/packages/mui-material/src/Popper/BasePopper.types.ts
@@ -0,0 +1,155 @@
+import * as React from 'react';
+import { Instance, Options, OptionsGeneric, VirtualElement } from '@popperjs/core';
+import { PortalProps } from '../Portal';
+import { SlotComponentProps } from '../utils/types';
+import { PolymorphicProps } from '../utils/PolymorphicComponent';
+
+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/Popper.tsx b/packages/mui-material/src/Popper/Popper.tsx
index 43b354dcc48e90..48caf48487c4ab 100644
--- a/packages/mui-material/src/Popper/Popper.tsx
+++ b/packages/mui-material/src/Popper/Popper.tsx
@@ -1,12 +1,14 @@
'use client';
-import { Popper as BasePopper, PopperProps as BasePopperProps } from '@mui/base/Popper';
-import { Direction, SxProps } from '@mui/system';
-import useTheme from '@mui/system/useThemeWithoutDefault';
+import { SxProps } from '@mui/system';
+import { useRtl } from '@mui/system/RtlProvider';
import refType from '@mui/utils/refType';
import HTMLElementType from '@mui/utils/HTMLElementType';
import PropTypes from 'prop-types';
import * as React from 'react';
-import { styled, Theme } from '../styles';
+import BasePopper from './BasePopper';
+import { PopperProps as BasePopperProps } from './BasePopper.types';
+import { Theme } from '../styles';
+import { styled } from '../zero-styled';
import { useDefaultProps } from '../DefaultPropsProvider';
export interface PopperProps extends Omit {
diff --git a/packages/mui-material/src/Popper/index.d.ts b/packages/mui-material/src/Popper/index.d.ts
index 50c058eb224616..f36e7aeba93a2a 100644
--- a/packages/mui-material/src/Popper/index.d.ts
+++ b/packages/mui-material/src/Popper/index.d.ts
@@ -1,3 +1,4 @@
export { default } from './Popper';
export * from './Popper';
-export { PopperPlacementType } from '@mui/base/Popper';
+export * from './popperClasses';
+export { PopperPlacementType } from './BasePopper.types';
diff --git a/packages/mui-material/src/Popper/index.js b/packages/mui-material/src/Popper/index.js
index 419f166db6fc01..3055f10dcc763e 100644
--- a/packages/mui-material/src/Popper/index.js
+++ b/packages/mui-material/src/Popper/index.js
@@ -1,2 +1,3 @@
'use client';
export { default } from './Popper';
+export * from './popperClasses';
diff --git a/packages/mui-material/src/Popper/popperClasses.ts b/packages/mui-material/src/Popper/popperClasses.ts
new file mode 100644
index 00000000000000..ee9153f2cfbf5f
--- /dev/null
+++ b/packages/mui-material/src/Popper/popperClasses.ts
@@ -0,0 +1,17 @@
+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);
+}
+
+const popperClasses: PopperClasses = generateUtilityClasses('MuiPopper', ['root']);
+
+export default popperClasses;
diff --git a/packages/mui-material/src/Portal/Portal.test.tsx b/packages/mui-material/src/Portal/Portal.test.tsx
new file mode 100644
index 00000000000000..3fe0b934c78bf5
--- /dev/null
+++ b/packages/mui-material/src/Portal/Portal.test.tsx
@@ -0,0 +1,220 @@
+import * as React from 'react';
+import { expect } from 'chai';
+import { spy } from 'sinon';
+import { createRenderer } from '@mui/internal-test-utils';
+import Portal, { PortalProps } from '@mui/material/Portal';
+
+describe(' ', () => {
+ const { render, renderToString } = createRenderer();
+
+ describe('server-side', () => {
+ before(function beforeHook() {
+ // Only run the test on node.
+ if (!/jsdom/.test(window.navigator.userAgent)) {
+ this.skip();
+ }
+ });
+
+ it('render nothing on the server', () => {
+ const { container } = renderToString(
+
+ Bar
+ ,
+ );
+ expect(container.firstChild).to.equal(null);
+ });
+ });
+
+ describe('ref', () => {
+ it('should have access to the mountNode when disabledPortal={false}', () => {
+ const refSpy = spy();
+ const { unmount } = render(
+
+ Foo
+ ,
+ );
+ expect(refSpy.args).to.deep.equal([[document.body]]);
+ unmount();
+ expect(refSpy.args).to.deep.equal([[document.body], [null]]);
+ });
+
+ it('should have access to the mountNode when disabledPortal={true}', () => {
+ const refSpy = spy();
+ const { unmount } = render(
+
+ Foo
+ ,
+ );
+ const mountNode = document.querySelector('.woofPortal');
+ expect(refSpy.args).to.deep.equal([[mountNode]]);
+ unmount();
+ expect(refSpy.args).to.deep.equal([[mountNode], [null]]);
+ });
+
+ it('should have access to the mountNode when switching disabledPortal', () => {
+ const refSpy = spy();
+ const { setProps, unmount } = render(
+
+ Foo
+ ,
+ );
+ const mountNode = document.querySelector('.woofPortal');
+ expect(refSpy.args).to.deep.equal([[mountNode]]);
+ setProps({
+ disablePortal: false,
+ ref: refSpy,
+ });
+ expect(refSpy.args).to.deep.equal([[mountNode], [null], [document.body]]);
+ unmount();
+ expect(refSpy.args).to.deep.equal([[mountNode], [null], [document.body], [null]]);
+ });
+ });
+
+ it('should render in a different node', () => {
+ render(
+ ,
+ );
+ const rootElement = document.querySelector('#test1')!;
+ expect(rootElement.contains(document.querySelector('.woofPortal1'))).to.equal(true);
+ expect(rootElement.contains(document.querySelector('.woofPortal2'))).to.equal(false);
+ });
+
+ it('should unmount when parent unmounts', () => {
+ function Child() {
+ const containerRef = React.useRef(null);
+ return (
+
+
+
containerRef.current}>
+
+
+
+ );
+ }
+
+ function Parent(props: { show?: boolean }) {
+ const { show = true } = props;
+ return {show ? : null}
;
+ }
+
+ const { setProps } = render( );
+ expect(document.querySelectorAll('#test1').length).to.equal(1);
+ setProps({ show: false });
+ expect(document.querySelectorAll('#test1').length).to.equal(0);
+ });
+
+ it('should render overlay into container (document)', () => {
+ render(
+
+
+
+ ,
+ );
+ expect(document.querySelectorAll('.test2').length).to.equal(2);
+ });
+
+ it('should render overlay into container (DOMNode)', () => {
+ const container = document.createElement('div');
+ render(
+
+
+ ,
+ );
+ expect(container.querySelectorAll('#test2').length).to.equal(1);
+ });
+
+ it('should change container on prop change', () => {
+ type ContainerProps = {
+ disablePortal?: boolean;
+ containerElement?: boolean;
+ };
+ function ContainerTest(props: ContainerProps) {
+ const { containerElement = false, disablePortal = true } = props;
+ const containerRef = React.useRef(null);
+ const container = React.useCallback(
+ () => (containerElement ? containerRef.current : null),
+ [containerElement],
+ );
+
+ return (
+
+
+
+
+
+
+ );
+ }
+
+ const { setProps } = render( );
+
+ expect(document.querySelector('#test3')?.parentElement?.nodeName).to.equal('SPAN');
+ setProps({
+ containerElement: true,
+ disablePortal: true,
+ });
+ expect(document.querySelector('#test3')?.parentElement?.nodeName).to.equal('SPAN');
+ setProps({
+ containerElement: true,
+ disablePortal: false,
+ });
+ expect(document.querySelector('#test3')?.parentElement?.nodeName).to.equal('STRONG');
+ setProps({
+ containerElement: false,
+ disablePortal: false,
+ });
+ expect(document.querySelector('#test3')?.parentElement?.nodeName).to.equal('BODY');
+ });
+
+ it('should call ref after child effect', () => {
+ const callOrder: Array = [];
+ const handleRef = (node: Element | null) => {
+ if (node) {
+ callOrder.push('ref');
+ }
+ };
+ const updateFunction = () => {
+ callOrder.push('effect');
+ };
+
+ function Test(props: PortalProps) {
+ const { container } = props;
+ const containerRef = React.useRef(null);
+
+ React.useEffect(() => {
+ if (containerRef.current !== container) {
+ updateFunction();
+ }
+ containerRef.current = container;
+ }, [container]);
+
+ return (
+
+
+
+ );
+ }
+
+ const { setProps } = render( );
+
+ setProps({ container: null });
+ setProps({ container: document.createElement('div') });
+ setProps({ container: null });
+
+ expect(callOrder).to.deep.equal([
+ 'effect',
+ 'ref',
+ 'effect',
+ 'ref',
+ 'effect',
+ 'ref',
+ 'effect',
+ 'ref',
+ ]);
+ });
+});
diff --git a/packages/mui-material/src/Portal/Portal.tsx b/packages/mui-material/src/Portal/Portal.tsx
new file mode 100644
index 00000000000000..fc076791ee4ef9
--- /dev/null
+++ b/packages/mui-material/src/Portal/Portal.tsx
@@ -0,0 +1,108 @@
+'use client';
+import * as React from 'react';
+import * as ReactDOM from 'react-dom';
+import PropTypes from 'prop-types';
+import {
+ exactProp,
+ HTMLElementType,
+ unstable_useEnhancedEffect as useEnhancedEffect,
+ unstable_useForkRef as useForkRef,
+ unstable_setRef as setRef,
+} from '@mui/utils';
+import { PortalProps } from './Portal.types';
+
+function getContainer(container: PortalProps['container']) {
+ return typeof container === 'function' ? container() : container;
+}
+
+/**
+ * Portals provide a first-class way to render children into a DOM node
+ * that exists outside the DOM hierarchy of the parent component.
+ *
+ * Demos:
+ *
+ * - [Portal](https://next.mui.com/material-ui/react-portal/)
+ *
+ * API:
+ *
+ * - [Portal API](https://next.mui.com/material-ui/api/portal/)
+ */
+const Portal = React.forwardRef(function Portal(
+ props: PortalProps,
+ forwardedRef: React.ForwardedRef,
+) {
+ const { children, container, disablePortal = false } = props;
+ const [mountNode, setMountNode] = React.useState>(null);
+ // @ts-expect-error TODO upstream fix
+ const handleRef = useForkRef(React.isValidElement(children) ? children.ref : null, forwardedRef);
+
+ useEnhancedEffect(() => {
+ if (!disablePortal) {
+ setMountNode(getContainer(container) || document.body);
+ }
+ }, [container, disablePortal]);
+
+ useEnhancedEffect(() => {
+ if (mountNode && !disablePortal) {
+ setRef(forwardedRef, mountNode);
+ return () => {
+ setRef(forwardedRef, null);
+ };
+ }
+
+ return undefined;
+ }, [forwardedRef, mountNode, disablePortal]);
+
+ if (disablePortal) {
+ if (React.isValidElement(children)) {
+ const newProps = {
+ ref: handleRef,
+ };
+ return React.cloneElement(children, newProps);
+ }
+ return {children} ;
+ }
+
+ return (
+
+ {mountNode ? ReactDOM.createPortal(children, mountNode) : mountNode}
+
+ );
+}) as React.ForwardRefExoticComponent>;
+
+Portal.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 children to render into the `container`.
+ */
+ children: PropTypes.node,
+ /**
+ * 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,
+ ]),
+ /**
+ * The `children` will be under the DOM hierarchy of the parent component.
+ * @default false
+ */
+ disablePortal: PropTypes.bool,
+} as any;
+
+if (process.env.NODE_ENV !== 'production') {
+ // eslint-disable-next-line
+ (Portal as any)['propTypes' + ''] = exactProp((Portal as any).propTypes);
+}
+
+export default Portal;
diff --git a/packages/mui-material/src/Portal/Portal.types.ts b/packages/mui-material/src/Portal/Portal.types.ts
new file mode 100644
index 00000000000000..8e5717db2742bf
--- /dev/null
+++ b/packages/mui-material/src/Portal/Portal.types.ts
@@ -0,0 +1,24 @@
+import * as React from 'react';
+
+export interface PortalProps {
+ /**
+ * The children to render into the `container`.
+ */
+ children?: 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?: Element | (() => Element | null) | null;
+ /**
+ * The `children` will be under the DOM hierarchy of the parent component.
+ * @default false
+ */
+ disablePortal?: boolean;
+}
diff --git a/packages/mui-material/src/Portal/index.d.ts b/packages/mui-material/src/Portal/index.d.ts
index 4a232ff978d006..4e97893ffd2919 100644
--- a/packages/mui-material/src/Portal/index.d.ts
+++ b/packages/mui-material/src/Portal/index.d.ts
@@ -1,2 +1,3 @@
-export { Portal as default } from '@mui/base/Portal';
-export * from '@mui/base/Portal';
+export { default } from './Portal';
+export * from './Portal';
+export * from './Portal.types';
diff --git a/packages/mui-material/src/Portal/index.js b/packages/mui-material/src/Portal/index.js
index 95c4c375680ad3..f227015fb984a9 100644
--- a/packages/mui-material/src/Portal/index.js
+++ b/packages/mui-material/src/Portal/index.js
@@ -1 +1 @@
-export { Portal as default } from '@mui/base/Portal';
+export { default } from './Portal';
diff --git a/packages/mui-material/src/Slider/Slider.d.ts b/packages/mui-material/src/Slider/Slider.d.ts
index e6ac5223f9302b..6aa0dc63c7c35c 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 { Mark } from './useSlider.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 d1a58c38b23795..f3394df821a315 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 '@mui/utils/useSlotProps';
+import { useSlider, valueToPercent } from './useSlider';
+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/Slider/Slider.test.js b/packages/mui-material/src/Slider/Slider.test.js
index 7e3207d337dcbc..07e62bf1bd03a6 100644
--- a/packages/mui-material/src/Slider/Slider.test.js
+++ b/packages/mui-material/src/Slider/Slider.test.js
@@ -2,8 +2,7 @@ import * as React from 'react';
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 { act, createRenderer, fireEvent, screen } from '@mui/internal-test-utils';
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..5f7ab6b85709a5
--- /dev/null
+++ b/packages/mui-material/src/Slider/useSlider.ts
@@ -0,0 +1,749 @@
+'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 extractEventHandlers from '@mui/utils/extractEventHandlers';
+import {
+ Mark,
+ UseSliderHiddenInputProps,
+ UseSliderParameters,
+ UseSliderReturnValue,
+ UseSliderRootSlotProps,
+ UseSliderThumbSlotProps,
+} from './useSlider.types';
+import { EventHandlers } from '../utils/types';
+import areArraysEqual from '../utils/areArraysEqual';
+
+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/Snackbar/Snackbar.d.ts b/packages/mui-material/src/Snackbar/Snackbar.d.ts
index 30b6fc17082d8b..789cea96e45b78 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 b7113636ead071..e206b120effa7a 100644
--- a/packages/mui-material/src/Snackbar/Snackbar.js
+++ b/packages/mui-material/src/Snackbar/Snackbar.js
@@ -1,12 +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 from '../styles/styled';
-import useTheme from '../styles/useTheme';
+import useSlotProps from '@mui/utils/useSlotProps';
+import useSnackbar from './useSnackbar';
+import ClickAwayListener from '../ClickAwayListener';
+import { styled, useTheme } from '../zero-styled';
import { useDefaultProps } from '../DefaultPropsProvider';
import capitalize from '../utils/capitalize';
import Grow from '../Grow';
diff --git a/packages/mui-material/src/Snackbar/useSnackbar.test.tsx b/packages/mui-material/src/Snackbar/useSnackbar.test.tsx
new file mode 100644
index 00000000000000..e50c8de3625d45
--- /dev/null
+++ b/packages/mui-material/src/Snackbar/useSnackbar.test.tsx
@@ -0,0 +1,54 @@
+import * as React from 'react';
+import { expect } from 'chai';
+import { spy } from 'sinon';
+import { fireEvent, createRenderer } from '@mui/internal-test-utils';
+import useSnackbar from './useSnackbar';
+import { UseSnackbarParameters } from './useSnackbar.types';
+
+describe('useSnackbar', () => {
+ const { render } = createRenderer();
+
+ const invokeUseSnackbar = (props: UseSnackbarParameters) => {
+ const ref = React.createRef>();
+ function TestComponent() {
+ const snackbarDefinition = useSnackbar(props);
+ React.useImperativeHandle(ref, () => snackbarDefinition, [snackbarDefinition]);
+ return null;
+ }
+
+ render( );
+
+ return ref.current!;
+ };
+
+ describe('getRootProps', () => {
+ it('returns props for the root slot', () => {
+ const props: UseSnackbarParameters = {};
+
+ const { getRootProps } = invokeUseSnackbar(props);
+
+ const rootProps = getRootProps();
+
+ expect(rootProps.role).to.equal('presentation');
+ });
+
+ it('forwards external props including event handlers', () => {
+ const handleClickSpy = spy();
+
+ function Snackbar() {
+ const { getRootProps } = useSnackbar();
+
+ return
;
+ }
+ const { getByRole } = render( );
+
+ const snackbar = getByRole('presentation');
+
+ expect(snackbar).to.have.attribute('random', 'arbitraryValue');
+
+ fireEvent.click(snackbar);
+
+ expect(handleClickSpy.callCount).to.equal(1);
+ });
+ });
+});
diff --git a/packages/mui-material/src/Snackbar/useSnackbar.ts b/packages/mui-material/src/Snackbar/useSnackbar.ts
new file mode 100644
index 00000000000000..17316a0a56ce83
--- /dev/null
+++ b/packages/mui-material/src/Snackbar/useSnackbar.ts
@@ -0,0 +1,166 @@
+'use client';
+import * as React from 'react';
+import {
+ unstable_useEventCallback as useEventCallback,
+ unstable_useTimeout as useTimeout,
+} from '@mui/utils';
+import extractEventHandlers from '@mui/utils/extractEventHandlers';
+import {
+ UseSnackbarParameters,
+ SnackbarCloseReason,
+ UseSnackbarReturnValue,
+} from './useSnackbar.types';
+import { EventHandlers } from '../utils/types';
+
+/**
+ * The basic building block for creating custom snackbar.
+ *
+ * Demos:
+ *
+ * - [Snackbar](https://next.mui.com/base-ui/react-snackbar/#hook)
+ *
+ * API:
+ *
+ * - [useSnackbar API](https://next.mui.com/base-ui/react-snackbar/hooks-api/#use-snackbar)
+ */
+function useSnackbar(parameters: UseSnackbarParameters = {}): UseSnackbarReturnValue {
+ const {
+ autoHideDuration = null,
+ disableWindowBlurListener = false,
+ onClose,
+ open,
+ resumeHideDuration,
+ } = parameters;
+
+ const timerAutoHide = useTimeout();
+
+ React.useEffect(() => {
+ if (!open) {
+ return undefined;
+ }
+
+ /**
+ * @param {KeyboardEvent} nativeEvent
+ */
+ function handleKeyDown(nativeEvent: KeyboardEvent) {
+ if (!nativeEvent.defaultPrevented) {
+ if (nativeEvent.key === 'Escape') {
+ // not calling `preventDefault` since we don't know if people may ignore this event e.g. a permanently open snackbar
+ onClose?.(nativeEvent, 'escapeKeyDown');
+ }
+ }
+ }
+
+ document.addEventListener('keydown', handleKeyDown);
+
+ return () => {
+ document.removeEventListener('keydown', handleKeyDown);
+ };
+ }, [open, onClose]);
+
+ const handleClose = useEventCallback((event: null, reason: SnackbarCloseReason) => {
+ onClose?.(event, reason);
+ });
+
+ const setAutoHideTimer = useEventCallback((autoHideDurationParam: number | null) => {
+ if (!onClose || autoHideDurationParam == null) {
+ return;
+ }
+
+ timerAutoHide.start(autoHideDurationParam, () => {
+ handleClose(null, 'timeout');
+ });
+ });
+
+ React.useEffect(() => {
+ if (open) {
+ setAutoHideTimer(autoHideDuration);
+ }
+
+ return timerAutoHide.clear;
+ }, [open, autoHideDuration, setAutoHideTimer, timerAutoHide]);
+
+ const handleClickAway = (event: React.SyntheticEvent | Event) => {
+ onClose?.(event, 'clickaway');
+ };
+
+ // Pause the timer when the user is interacting with the Snackbar
+ // or when the user hide the window.
+ const handlePause = timerAutoHide.clear;
+
+ // Restart the timer when the user is no longer interacting with the Snackbar
+ // or when the window is shown back.
+ const handleResume = React.useCallback(() => {
+ if (autoHideDuration != null) {
+ setAutoHideTimer(resumeHideDuration != null ? resumeHideDuration : autoHideDuration * 0.5);
+ }
+ }, [autoHideDuration, resumeHideDuration, setAutoHideTimer]);
+
+ const createHandleBlur =
+ (otherHandlers: EventHandlers) => (event: React.FocusEvent) => {
+ const onBlurCallback = otherHandlers.onBlur;
+ onBlurCallback?.(event);
+ handleResume();
+ };
+
+ const createHandleFocus =
+ (otherHandlers: EventHandlers) => (event: React.FocusEvent) => {
+ const onFocusCallback = otherHandlers.onFocus;
+ onFocusCallback?.(event);
+ handlePause();
+ };
+
+ const createMouseEnter =
+ (otherHandlers: EventHandlers) => (event: React.MouseEvent) => {
+ const onMouseEnterCallback = otherHandlers.onMouseEnter;
+ onMouseEnterCallback?.(event);
+ handlePause();
+ };
+
+ const createMouseLeave =
+ (otherHandlers: EventHandlers) => (event: React.MouseEvent) => {
+ const onMouseLeaveCallback = otherHandlers.onMouseLeave;
+ onMouseLeaveCallback?.(event);
+ handleResume();
+ };
+
+ React.useEffect(() => {
+ // TODO: window global should be refactored here
+ if (!disableWindowBlurListener && open) {
+ window.addEventListener('focus', handleResume);
+ window.addEventListener('blur', handlePause);
+
+ return () => {
+ window.removeEventListener('focus', handleResume);
+ window.removeEventListener('blur', handlePause);
+ };
+ }
+
+ return undefined;
+ }, [disableWindowBlurListener, open, handleResume, handlePause]);
+
+ const getRootProps = = {}>(
+ externalProps: ExternalProps = {} as ExternalProps,
+ ) => {
+ const externalEventHandlers = {
+ ...extractEventHandlers(parameters),
+ ...extractEventHandlers(externalProps),
+ };
+
+ return {
+ // ClickAwayListener adds an `onClick` prop which results in the alert not being announced.
+ // See https://github.com/mui/material-ui/issues/29080
+ role: 'presentation',
+ ...externalProps,
+ ...externalEventHandlers,
+ onBlur: createHandleBlur(externalEventHandlers),
+ onFocus: createHandleFocus(externalEventHandlers),
+ onMouseEnter: createMouseEnter(externalEventHandlers),
+ onMouseLeave: createMouseLeave(externalEventHandlers),
+ };
+ };
+
+ return { getRootProps, onClickAway: handleClickAway };
+}
+
+export default useSnackbar;
diff --git a/packages/mui-material/src/Snackbar/useSnackbar.types.ts b/packages/mui-material/src/Snackbar/useSnackbar.types.ts
new file mode 100644
index 00000000000000..845fb51760bbfe
--- /dev/null
+++ b/packages/mui-material/src/Snackbar/useSnackbar.types.ts
@@ -0,0 +1,66 @@
+export type SnackbarCloseReason = 'timeout' | 'clickaway' | 'escapeKeyDown';
+
+export interface UseSnackbarParameters {
+ /**
+ * The number of milliseconds to wait before automatically calling the
+ * `onClose` function. `onClose` should then set the state of the `open`
+ * prop to hide the Snackbar. This behavior is disabled by default with
+ * the `null` value.
+ * @default null
+ */
+ autoHideDuration?: number | null;
+ /**
+ * If `true`, the `autoHideDuration` timer will expire even if the window is not focused.
+ * @default false
+ */
+ disableWindowBlurListener?: boolean;
+ /**
+ * Callback fired when the component requests to be closed.
+ * Typically `onClose` is used to set state in the parent component,
+ * which is used to control the `Snackbar` `open` prop.
+ * The `reason` parameter can optionally be used to control the response to `onClose`,
+ * for example ignoring `clickaway`.
+ *
+ * @param {React.SyntheticEvent | Event} event The event source of the callback.
+ * @param {string} reason Can be: `"timeout"` (`autoHideDuration` expired), `"clickaway"`, or `"escapeKeyDown"`.
+ */
+ onClose?: (event: React.SyntheticEvent | Event | null, reason: SnackbarCloseReason) => void;
+ /**
+ * If `true`, the component is shown.
+ */
+ open?: boolean;
+ /**
+ * The number of milliseconds to wait before dismissing after user interaction.
+ * If `autoHideDuration` prop isn't specified, it does nothing.
+ * If `autoHideDuration` prop is specified but `resumeHideDuration` isn't,
+ * we default to `autoHideDuration / 2` ms.
+ */
+ resumeHideDuration?: number;
+}
+
+export type UseSnackbarRootSlotProps = ExternalProps &
+ UseSnackbarRootSlotOwnProps;
+
+export interface UseSnackbarRootSlotOwnProps {
+ onBlur: React.FocusEventHandler;
+ onFocus: React.FocusEventHandler;
+ onMouseEnter: React.MouseEventHandler;
+ onMouseLeave: React.MouseEventHandler;
+ ref?: React.RefCallback;
+ role: React.AriaRole;
+}
+
+export interface UseSnackbarReturnValue {
+ /**
+ * 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,
+ ) => UseSnackbarRootSlotProps;
+ /**
+ * Callback fired when a "click away" event is detected.
+ */
+ onClickAway: (event: React.SyntheticEvent | Event) => void;
+}
diff --git a/packages/mui-material/src/SwipeableDrawer/SwipeableDrawer.js b/packages/mui-material/src/SwipeableDrawer/SwipeableDrawer.js
index 2df878337ba528..95b59d893ec2a2 100644
--- a/packages/mui-material/src/SwipeableDrawer/SwipeableDrawer.js
+++ b/packages/mui-material/src/SwipeableDrawer/SwipeableDrawer.js
@@ -3,8 +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 { useDefaultProps } from '../DefaultPropsProvider';
+import NoSsr from '../NoSsr';
import Drawer, { getAnchor, isHorizontal } from '../Drawer/Drawer';
import useForkRef from '../utils/useForkRef';
import ownerDocument from '../utils/ownerDocument';
diff --git a/packages/mui-material/src/TabScrollButton/TabScrollButton.d.ts b/packages/mui-material/src/TabScrollButton/TabScrollButton.d.ts
index e8d396e4a49b12..87e348da3ff018 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 b5f5a6ec779e15..7807f50f3ab704 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 '@mui/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 c34ecc4ccf5248..ee8c1e7cd8b047 100644
--- a/packages/mui-material/src/TablePagination/TablePagination.js
+++ b/packages/mui-material/src/TablePagination/TablePagination.js
@@ -4,9 +4,9 @@ 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 styled from '../styles/styled';
+import isHostComponent from '../utils/isHostComponent';
+import { styled } from '../zero-styled';
import { useDefaultProps } from '../DefaultPropsProvider';
import InputBase from '../InputBase';
import MenuItem from '../MenuItem';
diff --git a/packages/mui-material/src/Tabs/Tabs.d.ts b/packages/mui-material/src/Tabs/Tabs.d.ts
index 32824ee0054d7e..24b13a4911988f 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 405d57c1820539..9f067d7750f6ff 100644
--- a/packages/mui-material/src/Tabs/Tabs.js
+++ b/packages/mui-material/src/Tabs/Tabs.js
@@ -4,10 +4,10 @@ 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 composeClasses from '@mui/utils/composeClasses';
import { useRtl } from '@mui/system/RtlProvider';
-import styled from '../styles/styled';
+import useSlotProps from '@mui/utils/useSlotProps';
+import { styled, useTheme } from '../zero-styled';
import { useDefaultProps } from '../DefaultPropsProvider';
import useTheme from '../styles/useTheme';
import debounce from '../utils/debounce';
diff --git a/packages/mui-material/src/TextareaAutosize/TextareaAutosize.test.tsx b/packages/mui-material/src/TextareaAutosize/TextareaAutosize.test.tsx
new file mode 100644
index 00000000000000..114699b8ef1121
--- /dev/null
+++ b/packages/mui-material/src/TextareaAutosize/TextareaAutosize.test.tsx
@@ -0,0 +1,448 @@
+import * as React from 'react';
+import { expect } from 'chai';
+import sinon, { spy, stub } from 'sinon';
+import { act, screen, waitFor, createRenderer, fireEvent } from '@mui/internal-test-utils';
+import TextareaAutosize from '@mui/material/TextareaAutosize';
+
+function getStyleValue(value: string) {
+ return parseInt(value, 10) || 0;
+}
+
+// TODO: merge into a shared test helpers.
+// MUI X already have one under mui-x/test/utils/helperFn.ts
+function sleep(duration: number): Promise {
+ return new Promise((resolve) => {
+ setTimeout(() => {
+ resolve();
+ }, duration);
+ });
+}
+
+async function raf() {
+ return new Promise((resolve) => {
+ // Chrome and Safari have a bug where calling rAF once returns the current
+ // frame instead of the next frame, so we need to call a double rAF here.
+ // See crbug.com/675795 for more.
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ resolve();
+ });
+ });
+ });
+}
+
+describe(' ', () => {
+ const { clock, render } = createRenderer();
+
+ // For https://github.com/mui/material-ui/pull/33238
+ it('should not crash when unmounting with Suspense', async () => {
+ const LazyRoute = React.lazy(() => {
+ // Force react to show fallback suspense
+ return new Promise((resolve) => {
+ setTimeout(() => {
+ resolve({
+ default: () => LazyRoute
,
+ });
+ }, 0);
+ });
+ });
+
+ function App() {
+ const [toggle, setToggle] = React.useState(false);
+
+ return (
+
+ setToggle((r) => !r)}>Toggle
+ {toggle ? : }
+
+ );
+ }
+
+ render( );
+ const button = screen.getByRole('button');
+ fireEvent.click(button);
+ await waitFor(() => {
+ expect(screen.queryByText('LazyRoute')).not.to.equal(null);
+ });
+ });
+
+ // For https://github.com/mui/material-ui/pull/33253
+ it('should update height without an infinite rendering loop', async () => {
+ function App() {
+ const [value, setValue] = React.useState('Controlled');
+
+ const handleChange = (event: React.ChangeEvent) => {
+ setValue(event.target.value);
+ };
+
+ return ;
+ }
+ const { container } = render( );
+ const input = container.querySelector('textarea')!;
+ act(() => {
+ input.focus();
+ });
+ const activeElement = document.activeElement!;
+ // set the value of the input to be 1 larger than its content width
+ fireEvent.change(activeElement, {
+ target: { value: 'Controlled\n' },
+ });
+ await sleep(0);
+ fireEvent.change(activeElement, {
+ target: { value: 'Controlled\n\n' },
+ });
+ });
+
+ // For https://github.com/mui/material-ui/pull/37135
+ it('should update height without delay', async function test() {
+ if (/jsdom/.test(window.navigator.userAgent)) {
+ // It depends on ResizeObserver
+ this.skip();
+ }
+
+ function App() {
+ const ref = React.useRef(null);
+ return (
+
+
{
+ ref.current!.style.width = '250px';
+ }}
+ >
+ change
+
+
+
+
+
+ );
+ }
+ const { container } = render( );
+ const input = container.querySelector('textarea')!;
+ const button = screen.getByRole('button');
+ expect(parseInt(input.style.height, 10)).to.be.within(30, 32);
+ fireEvent.click(button);
+ await raf();
+ await raf();
+ expect(parseInt(input.style.height, 10)).to.be.within(15, 17);
+ });
+
+ describe('layout', () => {
+ const getComputedStyleStub = new Map>();
+ function setLayout(
+ input: HTMLTextAreaElement,
+ shadow: Element,
+ {
+ getComputedStyle,
+ scrollHeight: scrollHeightArg,
+ lineHeight: lineHeightArg,
+ }: {
+ getComputedStyle: Partial;
+ scrollHeight?: number | (() => number);
+ lineHeight?: number | (() => number);
+ },
+ ) {
+ const lineHeight = typeof lineHeightArg === 'function' ? lineHeightArg : () => lineHeightArg;
+ const scrollHeight =
+ typeof scrollHeightArg === 'function' ? scrollHeightArg : () => scrollHeightArg;
+
+ getComputedStyleStub.set(input, getComputedStyle);
+
+ let index = 0;
+ stub(shadow, 'scrollHeight').get(() => {
+ index += 1;
+ return index % 2 === 1 ? scrollHeight() : lineHeight();
+ });
+ }
+
+ before(function beforeHook() {
+ // Only run the test on node.
+ if (!/jsdom/.test(window.navigator.userAgent)) {
+ this.skip();
+ }
+
+ stub(window, 'getComputedStyle').value(
+ (node: Element) => getComputedStyleStub.get(node) || {},
+ );
+ });
+
+ after(() => {
+ sinon.restore();
+ });
+
+ describe('resize', () => {
+ clock.withFakeTimers();
+
+ it('should handle the resize event', () => {
+ const { container } = render( );
+ const input = container.querySelector('textarea[aria-hidden=null]')!;
+ const shadow = container.querySelector('textarea[aria-hidden=true]')!;
+
+ expect(input.style).to.have.property('height', '0px');
+ expect(input.style).to.have.property('overflow', 'hidden');
+
+ setLayout(input, shadow, {
+ getComputedStyle: {
+ boxSizing: 'content-box',
+ },
+ scrollHeight: 30,
+ lineHeight: 15,
+ });
+ window.dispatchEvent(new window.Event('resize', {}));
+
+ clock.tick(166);
+
+ expect(input.style).to.have.property('height', '30px');
+ expect(input.style).to.have.property('overflow', 'hidden');
+ });
+ });
+
+ it('should update when uncontrolled', () => {
+ const handleChange = spy();
+ const { container } = render( );
+ const input = container.querySelector('textarea[aria-hidden=null]')!;
+ const shadow = container.querySelector('textarea[aria-hidden=true]')!;
+ expect(input.style).to.have.property('height', '0px');
+ expect(input.style).to.have.property('overflow', 'hidden');
+ setLayout(input, shadow, {
+ getComputedStyle: {
+ boxSizing: 'content-box',
+ },
+ scrollHeight: 30,
+ lineHeight: 15,
+ });
+ act(() => {
+ input.focus();
+ });
+ const activeElement = document.activeElement!;
+ fireEvent.change(activeElement, { target: { value: 'a' } });
+ expect(input.style).to.have.property('height', '30px');
+ expect(input.style).to.have.property('overflow', 'hidden');
+ expect(handleChange.callCount).to.equal(1);
+ });
+
+ it('should take the border into account with border-box', () => {
+ const border = 5;
+ const { container, forceUpdate } = render( );
+ const input = container.querySelector('textarea[aria-hidden=null]')!;
+ const shadow = container.querySelector('textarea[aria-hidden=true]')!;
+ expect(input.style).to.have.property('height', '0px');
+ expect(input.style).to.have.property('overflow', 'hidden');
+ setLayout(input, shadow, {
+ getComputedStyle: {
+ boxSizing: 'border-box',
+ borderBottomWidth: `${border}px`,
+ },
+ scrollHeight: 30,
+ lineHeight: 15,
+ });
+ forceUpdate();
+ expect(input.style).to.have.property('height', `${30 + border}px`);
+ expect(input.style).to.have.property('overflow', 'hidden');
+ });
+
+ it('should take the padding into account with content-box', () => {
+ const padding = 5;
+ const { container, forceUpdate } = render( );
+ const input = container.querySelector('textarea[aria-hidden=null]')!;
+ const shadow = container.querySelector('textarea[aria-hidden=true]')!;
+ setLayout(input, shadow, {
+ getComputedStyle: {
+ boxSizing: 'border-box',
+ paddingTop: `${padding}px`,
+ },
+ scrollHeight: 30,
+ lineHeight: 15,
+ });
+ forceUpdate();
+ expect(input.style).to.have.property('height', `${30 + padding}px`);
+ expect(input.style).to.have.property('overflow', 'hidden');
+ });
+
+ it('should have at least height of "minRows"', () => {
+ const minRows = 3;
+ const lineHeight = 15;
+ const { container, forceUpdate } = render( );
+ const input = container.querySelector('textarea[aria-hidden=null]')!;
+ const shadow = container.querySelector('textarea[aria-hidden=true]')!;
+ setLayout(input, shadow, {
+ getComputedStyle: {
+ boxSizing: 'content-box',
+ },
+ scrollHeight: 30,
+ lineHeight,
+ });
+ forceUpdate();
+ expect(input.style).to.have.property('height', `${lineHeight * minRows}px`);
+ expect(input.style).to.have.property('overflow', '');
+ });
+
+ it('should have at max "maxRows" rows', () => {
+ const maxRows = 3;
+ const lineHeight = 15;
+ const { container, forceUpdate } = render( );
+ const input = container.querySelector('textarea[aria-hidden=null]')!;
+ const shadow = container.querySelector('textarea[aria-hidden=true]')!;
+ setLayout(input, shadow, {
+ getComputedStyle: {
+ boxSizing: 'content-box',
+ },
+ scrollHeight: 100,
+ lineHeight,
+ });
+ forceUpdate();
+ expect(input.style).to.have.property('height', `${lineHeight * maxRows}px`);
+ expect(input.style).to.have.property('overflow', '');
+ });
+
+ it('should show scrollbar when having more rows than "maxRows"', () => {
+ const maxRows = 3;
+ const lineHeight = 15;
+ const { container, forceUpdate } = render( );
+ const input = container.querySelector('textarea[aria-hidden=null]')!;
+ const shadow = container.querySelector('textarea[aria-hidden=true]')!;
+ setLayout(input, shadow, {
+ getComputedStyle: {
+ boxSizing: 'border-box',
+ },
+ scrollHeight: lineHeight * 2,
+ lineHeight,
+ });
+ forceUpdate();
+ expect(input.style).to.have.property('height', `${lineHeight * 2}px`);
+ expect(input.style).to.have.property('overflow', 'hidden');
+ setLayout(input, shadow, {
+ getComputedStyle: {
+ boxSizing: 'border-box',
+ },
+ scrollHeight: lineHeight * 3,
+ lineHeight,
+ });
+ forceUpdate();
+ expect(input.style).to.have.property('height', `${lineHeight * 3}px`);
+ expect(input.style).to.have.property('overflow', 'hidden');
+ setLayout(input, shadow, {
+ getComputedStyle: {
+ boxSizing: 'border-box',
+ },
+ scrollHeight: lineHeight * 4,
+ lineHeight,
+ });
+ forceUpdate();
+ expect(input.style).to.have.property('height', `${lineHeight * 3}px`);
+ expect(input.style).to.have.property('overflow', '');
+ });
+
+ it('should update its height when the "maxRows" prop changes', () => {
+ const lineHeight = 15;
+ const { container, forceUpdate, setProps } = render( );
+ const input = container.querySelector('textarea[aria-hidden=null]')!;
+ const shadow = container.querySelector('textarea[aria-hidden=true]')!;
+ setLayout(input, shadow, {
+ getComputedStyle: {
+ boxSizing: 'content-box',
+ },
+ scrollHeight: 100,
+ lineHeight,
+ });
+ forceUpdate();
+ expect(input.style).to.have.property('height', `${lineHeight * 3}px`);
+ expect(input.style).to.have.property('overflow', '');
+ setProps({ maxRows: 2 });
+ expect(input.style).to.have.property('height', `${lineHeight * 2}px`);
+ expect(input.style).to.have.property('overflow', '');
+ });
+
+ it('should not sync height if container width is 0px', () => {
+ const lineHeight = 15;
+ const { container, forceUpdate } = render( );
+ const input = container.querySelector('textarea[aria-hidden=null]')!;
+ const shadow = container.querySelector('textarea[aria-hidden=true]')!;
+
+ setLayout(input, shadow, {
+ getComputedStyle: {
+ boxSizing: 'content-box',
+ },
+ scrollHeight: lineHeight * 2,
+ lineHeight,
+ });
+ forceUpdate();
+
+ expect(input.style).to.have.property('height', `${lineHeight * 2}px`);
+ expect(input.style).to.have.property('overflow', 'hidden');
+
+ setLayout(input, shadow, {
+ getComputedStyle: {
+ boxSizing: 'content-box',
+ width: '0px',
+ },
+ scrollHeight: lineHeight * 3,
+ lineHeight,
+ });
+
+ forceUpdate();
+ expect(input.style).to.have.property('height', `${lineHeight * 2}px`);
+ expect(input.style).to.have.property('overflow', 'hidden');
+ });
+
+ it('should compute the correct height if padding-right is greater than 0px', () => {
+ const paddingRight = 50;
+ const { container, forceUpdate } = render( );
+ const input = container.querySelector('textarea[aria-hidden=null]')!;
+ const shadow = container.querySelector('textarea[aria-hidden=true]')! as HTMLTextAreaElement;
+ const contentWidth = 100;
+ const lineHeight = 15;
+ const width = contentWidth + paddingRight;
+ setLayout(input, shadow, {
+ getComputedStyle: {
+ boxSizing: 'border-box',
+ width: `${width}px`,
+ },
+ scrollHeight: () => {
+ // assuming that the width of the word is 1px, and substract the width of the paddingRight
+ const lineNum = Math.ceil(
+ input.value.length / (width - getStyleValue(shadow.style.paddingRight)),
+ );
+ return lineNum * lineHeight;
+ },
+ lineHeight,
+ });
+
+ act(() => {
+ input.focus();
+ });
+ const activeElement = document.activeElement!;
+ // set the value of the input to be 1 larger than its content width
+ fireEvent.change(activeElement, {
+ target: { value: new Array(contentWidth + 1).fill('a').join('') },
+ });
+ forceUpdate();
+
+ // the input should be 2 lines
+ expect(input.style).to.have.property('height', `${lineHeight * 2}px`);
+ });
+ });
+
+ it('should apply the inline styles using the "style" prop', function test() {
+ if (/jsdom/.test(window.navigator.userAgent)) {
+ this.skip();
+ }
+
+ const { container } = render( );
+ const input = container.querySelector('textarea')!;
+
+ expect(input).toHaveComputedStyle({
+ backgroundColor: 'rgb(255, 255, 0)',
+ });
+ });
+});
diff --git a/packages/mui-material/src/TextareaAutosize/TextareaAutosize.tsx b/packages/mui-material/src/TextareaAutosize/TextareaAutosize.tsx
new file mode 100644
index 00000000000000..c284416fd79f87
--- /dev/null
+++ b/packages/mui-material/src/TextareaAutosize/TextareaAutosize.tsx
@@ -0,0 +1,264 @@
+'use client';
+import * as React from 'react';
+import PropTypes from 'prop-types';
+import {
+ unstable_debounce as debounce,
+ unstable_useForkRef as useForkRef,
+ unstable_useEnhancedEffect as useEnhancedEffect,
+ unstable_ownerWindow as ownerWindow,
+} from '@mui/utils';
+import { TextareaAutosizeProps } from './TextareaAutosize.types';
+
+function getStyleValue(value: string) {
+ return parseInt(value, 10) || 0;
+}
+
+const styles: {
+ shadow: React.CSSProperties;
+} = {
+ shadow: {
+ // Visibility needed to hide the extra text area on iPads
+ visibility: 'hidden',
+ // Remove from the content flow
+ position: 'absolute',
+ // Ignore the scrollbar width
+ overflow: 'hidden',
+ height: 0,
+ top: 0,
+ left: 0,
+ // Create a new layer, increase the isolation of the computed values
+ transform: 'translateZ(0)',
+ },
+};
+
+type TextareaStyles = {
+ outerHeightStyle: number;
+ overflowing: boolean;
+};
+
+function isEmpty(obj: TextareaStyles) {
+ return (
+ obj === undefined ||
+ obj === null ||
+ Object.keys(obj).length === 0 ||
+ (obj.outerHeightStyle === 0 && !obj.overflowing)
+ );
+}
+
+/**
+ *
+ * Demos:
+ *
+ * - [Textarea Autosize](https://next.mui.com/material-ui/react-textarea-autosize/)
+ *
+ * API:
+ *
+ * - [TextareaAutosize API](https://next.mui.com/material-ui/api/textarea-autosize/)
+ */
+const TextareaAutosize = React.forwardRef(function TextareaAutosize(
+ props: TextareaAutosizeProps,
+ forwardedRef: React.ForwardedRef,
+) {
+ const { onChange, maxRows, minRows = 1, style, value, ...other } = props;
+
+ const { current: isControlled } = React.useRef(value != null);
+ const inputRef = React.useRef(null);
+ const handleRef = useForkRef(forwardedRef, inputRef);
+ const heightRef = React.useRef(null);
+ const shadowRef = React.useRef(null);
+
+ const calculateTextareaStyles = React.useCallback(() => {
+ const input = inputRef.current!;
+
+ const containerWindow = ownerWindow(input);
+ const computedStyle = containerWindow.getComputedStyle(input);
+
+ // If input's width is shrunk and it's not visible, don't sync height.
+ if (computedStyle.width === '0px') {
+ return {
+ outerHeightStyle: 0,
+ overflowing: false,
+ };
+ }
+
+ const inputShallow = shadowRef.current!;
+
+ inputShallow.style.width = computedStyle.width;
+ inputShallow.value = input.value || props.placeholder || 'x';
+ if (inputShallow.value.slice(-1) === '\n') {
+ // Certain fonts which overflow the line height will cause the textarea
+ // to report a different scrollHeight depending on whether the last line
+ // is empty. Make it non-empty to avoid this issue.
+ inputShallow.value += ' ';
+ }
+
+ const boxSizing = computedStyle.boxSizing;
+ const padding =
+ getStyleValue(computedStyle.paddingBottom) + getStyleValue(computedStyle.paddingTop);
+ const border =
+ getStyleValue(computedStyle.borderBottomWidth) + getStyleValue(computedStyle.borderTopWidth);
+
+ // The height of the inner content
+ const innerHeight = inputShallow.scrollHeight;
+
+ // Measure height of a textarea with a single row
+ inputShallow.value = 'x';
+ const singleRowHeight = inputShallow.scrollHeight;
+
+ // The height of the outer content
+ let outerHeight = innerHeight;
+
+ if (minRows) {
+ outerHeight = Math.max(Number(minRows) * singleRowHeight, outerHeight);
+ }
+ if (maxRows) {
+ outerHeight = Math.min(Number(maxRows) * singleRowHeight, outerHeight);
+ }
+ outerHeight = Math.max(outerHeight, singleRowHeight);
+
+ // Take the box sizing into account for applying this value as a style.
+ const outerHeightStyle = outerHeight + (boxSizing === 'border-box' ? padding + border : 0);
+ const overflowing = Math.abs(outerHeight - innerHeight) <= 1;
+
+ return { outerHeightStyle, overflowing };
+ }, [maxRows, minRows, props.placeholder]);
+
+ const syncHeight = React.useCallback(() => {
+ const textareaStyles = calculateTextareaStyles();
+
+ if (isEmpty(textareaStyles)) {
+ return;
+ }
+
+ const outerHeightStyle = textareaStyles.outerHeightStyle;
+ const input = inputRef.current!;
+ if (heightRef.current !== outerHeightStyle) {
+ heightRef.current = outerHeightStyle;
+ input.style.height = `${outerHeightStyle}px`;
+ }
+ input.style.overflow = textareaStyles.overflowing ? 'hidden' : '';
+ }, [calculateTextareaStyles]);
+
+ useEnhancedEffect(() => {
+ const handleResize = () => {
+ syncHeight();
+ };
+ // Workaround a "ResizeObserver loop completed with undelivered notifications" error
+ // in test.
+ // Note that we might need to use this logic in production per https://github.com/WICG/resize-observer/issues/38
+ // Also see https://github.com/mui/mui-x/issues/8733
+ let rAF: any;
+ const rAFHandleResize = () => {
+ cancelAnimationFrame(rAF);
+ rAF = requestAnimationFrame(() => {
+ handleResize();
+ });
+ };
+ const debounceHandleResize = debounce(handleResize);
+ const input = inputRef.current!;
+ const containerWindow = ownerWindow(input);
+
+ containerWindow.addEventListener('resize', debounceHandleResize);
+
+ let resizeObserver: ResizeObserver;
+
+ if (typeof ResizeObserver !== 'undefined') {
+ resizeObserver = new ResizeObserver(
+ process.env.NODE_ENV === 'test' ? rAFHandleResize : handleResize,
+ );
+ resizeObserver.observe(input);
+ }
+
+ return () => {
+ debounceHandleResize.clear();
+ cancelAnimationFrame(rAF);
+ containerWindow.removeEventListener('resize', debounceHandleResize);
+ if (resizeObserver) {
+ resizeObserver.disconnect();
+ }
+ };
+ }, [calculateTextareaStyles, syncHeight]);
+
+ useEnhancedEffect(() => {
+ syncHeight();
+ });
+
+ const handleChange = (event: React.ChangeEvent) => {
+ if (!isControlled) {
+ syncHeight();
+ }
+
+ if (onChange) {
+ onChange(event);
+ }
+ };
+
+ return (
+
+
+
+
+ );
+}) as React.ForwardRefExoticComponent>;
+
+TextareaAutosize.propTypes /* remove-proptypes */ = {
+ // ┌────────────────────────────── Warning ──────────────────────────────┐
+ // │ These PropTypes are generated from the TypeScript type definitions. │
+ // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
+ // └─────────────────────────────────────────────────────────────────────┘
+ /**
+ * @ignore
+ */
+ className: PropTypes.string,
+ /**
+ * Maximum number of rows to display.
+ */
+ maxRows: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
+ /**
+ * Minimum number of rows to display.
+ * @default 1
+ */
+ minRows: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
+ /**
+ * @ignore
+ */
+ onChange: PropTypes.func,
+ /**
+ * @ignore
+ */
+ placeholder: PropTypes.string,
+ /**
+ * @ignore
+ */
+ style: PropTypes.object,
+ /**
+ * @ignore
+ */
+ value: PropTypes.oneOfType([
+ PropTypes.arrayOf(PropTypes.string),
+ PropTypes.number,
+ PropTypes.string,
+ ]),
+} as any;
+
+export default TextareaAutosize;
diff --git a/packages/mui-material/src/TextareaAutosize/TextareaAutosize.types.ts b/packages/mui-material/src/TextareaAutosize/TextareaAutosize.types.ts
new file mode 100644
index 00000000000000..db83a932802cb3
--- /dev/null
+++ b/packages/mui-material/src/TextareaAutosize/TextareaAutosize.types.ts
@@ -0,0 +1,15 @@
+import * as React from 'react';
+
+export interface TextareaAutosizeProps
+ extends Omit, 'children' | 'rows'> {
+ ref?: React.Ref;
+ /**
+ * Maximum number of rows to display.
+ */
+ maxRows?: string | number;
+ /**
+ * Minimum number of rows to display.
+ * @default 1
+ */
+ minRows?: string | number;
+}
diff --git a/packages/mui-material/src/TextareaAutosize/index.d.ts b/packages/mui-material/src/TextareaAutosize/index.d.ts
index be1e871092aff7..5303e923bf86f5 100644
--- a/packages/mui-material/src/TextareaAutosize/index.d.ts
+++ b/packages/mui-material/src/TextareaAutosize/index.d.ts
@@ -1,2 +1,3 @@
-export { TextareaAutosize as default } from '@mui/base/TextareaAutosize';
-export * from '@mui/base/TextareaAutosize';
+export { default } from './TextareaAutosize';
+export * from './TextareaAutosize';
+export * from './TextareaAutosize.types';
diff --git a/packages/mui-material/src/TextareaAutosize/index.js b/packages/mui-material/src/TextareaAutosize/index.js
index 430b510ec4da1c..05c119fc457ddb 100644
--- a/packages/mui-material/src/TextareaAutosize/index.js
+++ b/packages/mui-material/src/TextareaAutosize/index.js
@@ -1 +1 @@
-export { TextareaAutosize as default } from '@mui/base/TextareaAutosize';
+export { default } from './TextareaAutosize';
diff --git a/packages/mui-material/src/Tooltip/Tooltip.js b/packages/mui-material/src/Tooltip/Tooltip.js
index 13e5dbfe89f3f3..0f289eb15c5662 100644
--- a/packages/mui-material/src/Tooltip/Tooltip.js
+++ b/packages/mui-material/src/Tooltip/Tooltip.js
@@ -4,12 +4,12 @@ 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 styled from '../styles/styled';
-import useTheme from '../styles/useTheme';
+import isFocusVisible from '@mui/utils/isFocusVisible';
+import appendOwnerState from '@mui/utils/appendOwnerState';
+import { styled, useTheme } from '../zero-styled';
import { useDefaultProps } from '../DefaultPropsProvider';
import capitalize from '../utils/capitalize';
import Grow from '../Grow';
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..546ca49a227568
--- /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/material/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(
+
+
+ ,
+ );
+ 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 && (
+
+ I am going to disappear
+
+ )}
+
+
+ );
+ }
+
+ 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..c1702350efd340
--- /dev/null
+++ b/packages/mui-material/src/Unstable_TrapFocus/FocusTrap.tsx
@@ -0,0 +1,426 @@
+'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, , and elements get a default
+ // `tabIndex` of -1 when the 'tabindex' attribute isn't specified in the DOM,
+ // yet they are still part of the regular tab order; in FF, they get a default
+ // `tabIndex` of 0; since Chrome still puts those elements in the regular tab
+ // order, consider their tab index to be 0.
+ if (
+ node.contentEditable === 'true' ||
+ ((node.nodeName === 'AUDIO' || node.nodeName === 'VIDEO' || node.nodeName === 'DETAILS') &&
+ node.getAttribute('tabindex') === null)
+ ) {
+ return 0;
+ }
+
+ return node.tabIndex;
+}
+
+function isNonTabbableRadio(node: HTMLInputElement): boolean {
+ if (node.tagName !== 'INPUT' || node.type !== 'radio') {
+ return false;
+ }
+
+ if (!node.name) {
+ return false;
+ }
+
+ const getRadio = (selector: string) =>
+ node.ownerDocument.querySelector(`input[type="radio"]${selector}`);
+
+ let roving = getRadio(`[name="${node.name}"]:checked`);
+
+ if (!roving) {
+ roving = getRadio(`[name="${node.name}"]`);
+ }
+
+ return roving !== node;
+}
+
+function isNodeMatchingSelectorFocusable(node: HTMLInputElement): boolean {
+ if (
+ node.disabled ||
+ (node.tagName === 'INPUT' && node.type === 'hidden') ||
+ isNonTabbableRadio(node)
+ ) {
+ return false;
+ }
+ return true;
+}
+
+function defaultGetTabbable(root: HTMLElement): HTMLElement[] {
+ const regularTabNodes: HTMLElement[] = [];
+ const orderedTabNodes: OrderedTabNode[] = [];
+
+ Array.from(root.querySelectorAll(candidatesSelector)).forEach((node, i) => {
+ const nodeTabIndex = getTabIndex(node as HTMLElement);
+
+ if (nodeTabIndex === -1 || !isNodeMatchingSelectorFocusable(node as HTMLInputElement)) {
+ return;
+ }
+
+ if (nodeTabIndex === 0) {
+ regularTabNodes.push(node as HTMLElement);
+ } else {
+ orderedTabNodes.push({
+ documentOrder: i,
+ tabIndex: nodeTabIndex,
+ node: node as HTMLElement,
+ });
+ }
+ });
+
+ return orderedTabNodes
+ .sort((a, b) =>
+ a.tabIndex === b.tabIndex ? a.documentOrder - b.documentOrder : a.tabIndex - b.tabIndex,
+ )
+ .map((a) => a.node)
+ .concat(regularTabNodes);
+}
+
+function defaultIsEnabled(): boolean {
+ return true;
+}
+
+/**
+ * @ignore - internal component.
+ */
+function FocusTrap(props: FocusTrapProps): React.JSX.Element {
+ const {
+ children,
+ disableAutoFocus = false,
+ disableEnforceFocus = false,
+ disableRestoreFocus = false,
+ getTabbable = defaultGetTabbable,
+ isEnabled = defaultIsEnabled,
+ open,
+ } = props;
+ const ignoreNextEnforceFocus = React.useRef(false);
+ const sentinelStart = React.useRef(null);
+ const sentinelEnd = React.useRef(null);
+ const nodeToRestore = React.useRef(null);
+ const reactFocusEventTarget = React.useRef(null);
+ // This variable is useful when disableAutoFocus is true.
+ // It waits for the active element to move into the component to activate.
+ const activated = React.useRef(false);
+
+ const rootRef = React.useRef(null);
+ // @ts-expect-error TODO upstream fix
+ const handleRef = useForkRef(children.ref, rootRef);
+ const lastKeydown = React.useRef(null);
+
+ React.useEffect(() => {
+ // We might render an empty child.
+ if (!open || !rootRef.current) {
+ return;
+ }
+
+ activated.current = !disableAutoFocus;
+ }, [disableAutoFocus, open]);
+
+ React.useEffect(() => {
+ // We might render an empty child.
+ if (!open || !rootRef.current) {
+ return;
+ }
+
+ const doc = ownerDocument(rootRef.current);
+
+ if (!rootRef.current.contains(doc.activeElement)) {
+ if (!rootRef.current.hasAttribute('tabIndex')) {
+ if (process.env.NODE_ENV !== 'production') {
+ console.error(
+ [
+ 'MUI: The modal content node does not accept focus.',
+ 'For the benefit of assistive technologies, ' +
+ 'the tabIndex of the node is being set to "-1".',
+ ].join('\n'),
+ );
+ }
+ rootRef.current.setAttribute('tabIndex', '-1');
+ }
+
+ if (activated.current) {
+ rootRef.current.focus();
+ }
+ }
+
+ return () => {
+ // restoreLastFocus()
+ if (!disableRestoreFocus) {
+ // In IE11 it is possible for document.activeElement to be null resulting
+ // in nodeToRestore.current being null.
+ // Not all elements in IE11 have a focus method.
+ // Once IE11 support is dropped the focus() call can be unconditional.
+ if (nodeToRestore.current && (nodeToRestore.current as HTMLElement).focus) {
+ ignoreNextEnforceFocus.current = true;
+ (nodeToRestore.current as HTMLElement).focus();
+ }
+
+ nodeToRestore.current = null;
+ }
+ };
+ // Missing `disableRestoreFocus` which is fine.
+ // We don't support changing that prop on an open FocusTrap
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [open]);
+
+ React.useEffect(() => {
+ // We might render an empty child.
+ if (!open || !rootRef.current) {
+ return;
+ }
+
+ const doc = ownerDocument(rootRef.current);
+
+ const loopFocus = (nativeEvent: KeyboardEvent) => {
+ lastKeydown.current = nativeEvent;
+
+ if (disableEnforceFocus || !isEnabled() || nativeEvent.key !== 'Tab') {
+ return;
+ }
+
+ // Make sure the next tab starts from the right place.
+ // doc.activeElement refers to the origin.
+ if (doc.activeElement === rootRef.current && nativeEvent.shiftKey) {
+ // We need to ignore the next contain as
+ // it will try to move the focus back to the rootRef element.
+ ignoreNextEnforceFocus.current = true;
+ if (sentinelEnd.current) {
+ sentinelEnd.current.focus();
+ }
+ }
+ };
+
+ const contain = () => {
+ const rootElement = rootRef.current;
+
+ // Cleanup functions are executed lazily in React 17.
+ // Contain can be called between the component being unmounted and its cleanup function being run.
+ if (rootElement === null) {
+ return;
+ }
+
+ if (!doc.hasFocus() || !isEnabled() || ignoreNextEnforceFocus.current) {
+ ignoreNextEnforceFocus.current = false;
+ return;
+ }
+
+ // The focus is already inside
+ if (rootElement.contains(doc.activeElement)) {
+ return;
+ }
+
+ // The disableEnforceFocus is set and the focus is outside of the focus trap (and sentinel nodes)
+ if (
+ disableEnforceFocus &&
+ doc.activeElement !== sentinelStart.current &&
+ doc.activeElement !== sentinelEnd.current
+ ) {
+ return;
+ }
+
+ // if the focus event is not coming from inside the children's react tree, reset the refs
+ if (doc.activeElement !== reactFocusEventTarget.current) {
+ reactFocusEventTarget.current = null;
+ } else if (reactFocusEventTarget.current !== null) {
+ return;
+ }
+
+ if (!activated.current) {
+ return;
+ }
+
+ let tabbable: ReadonlyArray | HTMLElement[] = [];
+ if (
+ doc.activeElement === sentinelStart.current ||
+ doc.activeElement === sentinelEnd.current
+ ) {
+ tabbable = getTabbable(rootRef.current!);
+ }
+
+ // one of the sentinel nodes was focused, so move the focus
+ // to the first/last tabbable element inside the focus trap
+ if (tabbable.length > 0) {
+ const isShiftTab = Boolean(
+ lastKeydown.current?.shiftKey && lastKeydown.current?.key === 'Tab',
+ );
+
+ const focusNext = tabbable[0];
+ const focusPrevious = tabbable[tabbable.length - 1];
+
+ if (typeof focusNext !== 'string' && typeof focusPrevious !== 'string') {
+ if (isShiftTab) {
+ focusPrevious.focus();
+ } else {
+ focusNext.focus();
+ }
+ }
+ // no tabbable elements in the trap focus or the focus was outside of the focus trap
+ } else {
+ rootElement.focus();
+ }
+ };
+
+ doc.addEventListener('focusin', contain);
+ doc.addEventListener('keydown', loopFocus, true);
+
+ // With Edge, Safari and Firefox, no focus related events are fired when the focused area stops being a focused area.
+ // for example https://bugzilla.mozilla.org/show_bug.cgi?id=559561.
+ // Instead, we can look if the active element was restored on the BODY element.
+ //
+ // The whatwg spec defines how the browser should behave but does not explicitly mention any events:
+ // https://html.spec.whatwg.org/multipage/interaction.html#focus-fixup-rule.
+ const interval = setInterval(() => {
+ if (doc.activeElement && doc.activeElement.tagName === 'BODY') {
+ contain();
+ }
+ }, 50);
+
+ return () => {
+ clearInterval(interval);
+
+ doc.removeEventListener('focusin', contain);
+ doc.removeEventListener('keydown', loopFocus, true);
+ };
+ }, [disableAutoFocus, disableEnforceFocus, disableRestoreFocus, isEnabled, open, getTabbable]);
+
+ const onFocus = (event: FocusEvent) => {
+ if (nodeToRestore.current === null) {
+ nodeToRestore.current = event.relatedTarget;
+ }
+ activated.current = true;
+ reactFocusEventTarget.current = event.target;
+
+ const childrenPropsHandler = children.props.onFocus;
+ if (childrenPropsHandler) {
+ childrenPropsHandler(event);
+ }
+ };
+
+ const handleFocusSentinel = (event: React.FocusEvent) => {
+ if (nodeToRestore.current === null) {
+ nodeToRestore.current = event.relatedTarget;
+ }
+ activated.current = true;
+ };
+
+ return (
+
+
+ {React.cloneElement(children, { ref: handleRef, onFocus })}
+
+
+ );
+}
+
+FocusTrap.propTypes /* remove-proptypes */ = {
+ // ┌────────────────────────────── Warning ──────────────────────────────┐
+ // │ These PropTypes are generated from the TypeScript type definitions. │
+ // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
+ // └─────────────────────────────────────────────────────────────────────┘
+ /**
+ * A single child content element.
+ */
+ children: elementAcceptingRef,
+ /**
+ * If `true`, the focus trap will not automatically shift focus to itself when it opens, and
+ * replace it to the last focused element when it closes.
+ * This also works correctly with any focus trap children that have the `disableAutoFocus` prop.
+ *
+ * Generally this should never be set to `true` as it makes the focus trap less
+ * accessible to assistive technologies, like screen readers.
+ * @default false
+ */
+ disableAutoFocus: PropTypes.bool,
+ /**
+ * If `true`, the focus trap will not prevent focus from leaving the focus trap while open.
+ *
+ * Generally this should never be set to `true` as it makes the focus trap less
+ * accessible to assistive technologies, like screen readers.
+ * @default false
+ */
+ disableEnforceFocus: PropTypes.bool,
+ /**
+ * If `true`, the focus trap will not restore focus to previously focused element once
+ * focus trap is hidden or unmounted.
+ * @default false
+ */
+ disableRestoreFocus: PropTypes.bool,
+ /**
+ * Returns an array of ordered tabbable nodes (i.e. in tab order) within the root.
+ * For instance, you can provide the "tabbable" npm dependency.
+ * @param {HTMLElement} root
+ */
+ getTabbable: PropTypes.func,
+ /**
+ * This prop extends the `open` prop.
+ * It allows to toggle the open state without having to wait for a rerender when changing the `open` prop.
+ * This prop should be memoized.
+ * It can be used to support multiple focus trap mounted at the same time.
+ * @default function defaultIsEnabled(): boolean {
+ * return true;
+ * }
+ */
+ isEnabled: PropTypes.func,
+ /**
+ * If `true`, focus is locked.
+ */
+ open: PropTypes.bool.isRequired,
+} as any;
+
+if (process.env.NODE_ENV !== 'production') {
+ // eslint-disable-next-line
+ (FocusTrap as any)['propTypes' + ''] = exactProp(FocusTrap.propTypes);
+}
+
+export default FocusTrap;
diff --git a/packages/mui-material/src/Unstable_TrapFocus/FocusTrap.types.ts b/packages/mui-material/src/Unstable_TrapFocus/FocusTrap.types.ts
new file mode 100644
index 00000000000000..be9cf5ee1e93c3
--- /dev/null
+++ b/packages/mui-material/src/Unstable_TrapFocus/FocusTrap.types.ts
@@ -0,0 +1,52 @@
+import * as React from 'react';
+
+export interface FocusTrapProps {
+ /**
+ * If `true`, focus is locked.
+ */
+ open: boolean;
+ /**
+ * Returns an array of ordered tabbable nodes (i.e. in tab order) within the root.
+ * For instance, you can provide the "tabbable" npm dependency.
+ * @param {HTMLElement} root
+ */
+ getTabbable?: (root: HTMLElement) => ReadonlyArray;
+ /**
+ * This prop extends the `open` prop.
+ * It allows to toggle the open state without having to wait for a rerender when changing the `open` prop.
+ * This prop should be memoized.
+ * It can be used to support multiple focus trap mounted at the same time.
+ * @default function defaultIsEnabled(): boolean {
+ * return true;
+ * }
+ */
+ isEnabled?: () => boolean;
+ /**
+ * A single child content element.
+ */
+ children: React.ReactElement;
+ /**
+ * If `true`, the focus trap will not automatically shift focus to itself when it opens, and
+ * replace it to the last focused element when it closes.
+ * This also works correctly with any focus trap children that have the `disableAutoFocus` prop.
+ *
+ * Generally this should never be set to `true` as it makes the focus trap less
+ * accessible to assistive technologies, like screen readers.
+ * @default false
+ */
+ disableAutoFocus?: boolean;
+ /**
+ * If `true`, the focus trap will not prevent focus from leaving the focus trap while open.
+ *
+ * Generally this should never be set to `true` as it makes the focus trap less
+ * accessible to assistive technologies, like screen readers.
+ * @default false
+ */
+ disableEnforceFocus?: boolean;
+ /**
+ * If `true`, the focus trap will not restore focus to previously focused element once
+ * focus trap is hidden or unmounted.
+ * @default false
+ */
+ disableRestoreFocus?: boolean;
+}
diff --git a/packages/mui-material/src/Unstable_TrapFocus/index.d.ts b/packages/mui-material/src/Unstable_TrapFocus/index.d.ts
index 820e99cce71041..7661a358f30ff0 100644
--- a/packages/mui-material/src/Unstable_TrapFocus/index.d.ts
+++ b/packages/mui-material/src/Unstable_TrapFocus/index.d.ts
@@ -1,2 +1,2 @@
-export { FocusTrap as default } from '@mui/base/FocusTrap';
-export { FocusTrapProps as TrapFocusProps } from '@mui/base/FocusTrap';
+export { default } from './FocusTrap';
+export { FocusTrapProps as TrapFocusProps } from './FocusTrap.types';
diff --git a/packages/mui-material/src/Unstable_TrapFocus/index.js b/packages/mui-material/src/Unstable_TrapFocus/index.js
index 164e00d3595e3a..5131ffa9169f3a 100644
--- a/packages/mui-material/src/Unstable_TrapFocus/index.js
+++ b/packages/mui-material/src/Unstable_TrapFocus/index.js
@@ -1 +1 @@
-export { FocusTrap as default } from '@mui/base/FocusTrap';
+export { default } from './FocusTrap';
diff --git a/packages/mui-material/src/index.d.ts b/packages/mui-material/src/index.d.ts
index 5e389716c185bb..c7e50f416ff072 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 { unstable_composeClasses } from '@mui/utils';
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..2546a29634a3a1 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 { unstable_composeClasses } from '@mui/utils';
export { default as generateUtilityClass } from './generateUtilityClass';
export * from './generateUtilityClass';
diff --git a/packages/mui-material/src/styles/overrides.d.ts b/packages/mui-material/src/styles/overrides.d.ts
index 408052a32e9cb5..c93ba7697f4de4 100644
--- a/packages/mui-material/src/styles/overrides.d.ts
+++ b/packages/mui-material/src/styles/overrides.d.ts
@@ -1,5 +1,5 @@
import { CSSObject, CSSInterpolation, Interpolation } from '@mui/system';
-import { PopperClassKey } from '@mui/base/Popper';
+import { PopperClassKey } from '../Popper';
import { ComponentsPropsList } from './props';
import { AccordionActionsClassKey } from '../AccordionActions';
import { AccordionClassKey } from '../Accordion';
diff --git a/packages/mui-material/src/useAutocomplete/useAutocomplete.d.ts b/packages/mui-material/src/useAutocomplete/useAutocomplete.d.ts
index 6091303d93a923..266f174f52b5f6 100644
--- a/packages/mui-material/src/useAutocomplete/useAutocomplete.d.ts
+++ b/packages/mui-material/src/useAutocomplete/useAutocomplete.d.ts
@@ -1,2 +1,468 @@
-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"`, `"blur"`, `"selectOption"`, `"removeOption"`
+ */
+ 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'
+ | 'blur'
+ | 'selectOption'
+ | 'removeOption';
+
+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>;
+}
+
+export default useAutocomplete;
diff --git a/packages/mui-material/src/useAutocomplete/useAutocomplete.js b/packages/mui-material/src/useAutocomplete/useAutocomplete.js
index a8f55d5c818452..b0f0a08c8f5dba 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, reason) => {
+ // 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, reason);
+ }
+ },
+ [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, 'reset');
+ }, [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, reason);
+
+ 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, 'blur');
+ }
+
+ 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..ffe5f780446fd6
--- /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 (
+
+
+ useAutocomplete
+
+
+ {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 (
+
+
+ useAutocomplete
+
+ {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();
+ });
+});
diff --git a/packages/mui-material/src/utils/PolymorphicComponent.ts b/packages/mui-material/src/utils/PolymorphicComponent.ts
new file mode 100644
index 00000000000000..abcc2682228f1f
--- /dev/null
+++ b/packages/mui-material/src/utils/PolymorphicComponent.ts
@@ -0,0 +1,25 @@
+import * as React from 'react';
+import { DistributiveOmit, OverridableTypeMap } from '@mui/types';
+
+/**
+ * A component whose root component can be controlled explicitly with a generic type parameter.
+ * Adjusts valid props based on the type of `RootComponent`.
+ *
+ * @template TypeMap The interface the defines the props and a default root element of the component.
+ */
+export type PolymorphicComponent = {
+ (
+ props: PolymorphicProps,
+ ): React.JSX.Element | null;
+ propTypes?: any;
+ displayName?: string | undefined;
+};
+
+/**
+ * Own props of the component augmented with props of the root component.
+ */
+export type PolymorphicProps<
+ TypeMap extends OverridableTypeMap,
+ RootComponent extends React.ElementType,
+> = TypeMap['props'] &
+ DistributiveOmit, keyof TypeMap['props']>;
diff --git a/packages/mui-material/src/utils/areArraysEqual.ts b/packages/mui-material/src/utils/areArraysEqual.ts
new file mode 100644
index 00000000000000..9fad9c0ed70447
--- /dev/null
+++ b/packages/mui-material/src/utils/areArraysEqual.ts
@@ -0,0 +1,14 @@
+type ItemComparer- = (a: Item, b: Item) => boolean;
+
+function areArraysEqual
- (
+ array1: ReadonlyArray
- ,
+ array2: ReadonlyArray
- ,
+ itemComparer: ItemComparer
- = (a, b) => a === b,
+) {
+ return (
+ array1.length === array2.length &&
+ array1.every((value, index) => itemComparer(value, array2[index]))
+ );
+}
+
+export default areArraysEqual;
diff --git a/packages/mui-material/src/utils/index.d.ts b/packages/mui-material/src/utils/index.d.ts
index 4f675ba362d039..7ccd7522ee6ff5 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,5 +15,4 @@ 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 { default as useIsFocusVisible } from './useIsFocusVisible';
-export { unstable_ClassNameGenerator } from '@mui/base/ClassNameGenerator';
+export * from './types';
diff --git a/packages/mui-material/src/utils/index.js b/packages/mui-material/src/utils/index.js
index 415505ff745dee..607f3987791777 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';
diff --git a/packages/mui-material/src/utils/isHostComponent.ts b/packages/mui-material/src/utils/isHostComponent.ts
new file mode 100644
index 00000000000000..f94464fe4e38a4
--- /dev/null
+++ b/packages/mui-material/src/utils/isHostComponent.ts
@@ -0,0 +1,10 @@
+import * as React from 'react';
+
+/**
+ * Determines if a given element is a DOM element name (i.e. not a React component).
+ */
+function isHostComponent(element: React.ElementType) {
+ return typeof element === 'string';
+}
+
+export default isHostComponent;
diff --git a/packages/mui-material/src/utils/omitEventHandlers.ts b/packages/mui-material/src/utils/omitEventHandlers.ts
new file mode 100644
index 00000000000000..f5dc4c7d908bee
--- /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.
+ */
+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;
+}
+
+export default omitEventHandlers;
diff --git a/packages/mui-material/src/utils/shouldSpreadAdditionalProps.js b/packages/mui-material/src/utils/shouldSpreadAdditionalProps.js
index eb22bb12531ae4..9c8ef7c062d30d 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..01d4a1be52612c 100644
--- a/packages/mui-material/src/utils/types.ts
+++ b/packages/mui-material/src/utils/types.ts
@@ -1,5 +1,12 @@
import { SxProps } from '@mui/system';
-import { SlotComponentProps } from '@mui/base';
+import { SlotComponentProps } from '@mui/utils';
+
+export type {
+ EventHandlers,
+ WithOptionalOwnerState,
+ SlotComponentProps,
+ SlotComponentPropsWithSlotState,
+} from '@mui/utils';
export type SlotCommonProps = {
component?: React.ElementType;
diff --git a/packages/mui-material/src/utils/useSlot.test.tsx b/packages/mui-material/src/utils/useSlot.test.tsx
index 7aab3a0233e590..62b0b3ea9a7356 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 { createRenderer } from '@mui/internal-test-utils';
+import Popper from '../Popper/BasePopper';
import { styled } from '../styles';
import { SlotProps } from './types';
import useSlot from './useSlot';
diff --git a/packages/mui-material/src/utils/useSlot.ts b/packages/mui-material/src/utils/useSlot.ts
index 53731186cd595d..94203e7356a431 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 '@mui/utils/appendOwnerState';
+import resolveComponentProps from '@mui/utils/resolveComponentProps';
+import mergeSlotProps from '@mui/utils/mergeSlotProps';
export type WithCommonProps = T & {
className?: string;
diff --git a/packages/mui-utils/package.json b/packages/mui-utils/package.json
index 3ffd3f01da48b3..2ae0a5c44b085e 100644
--- a/packages/mui-utils/package.json
+++ b/packages/mui-utils/package.json
@@ -41,6 +41,7 @@
"dependencies": {
"@babel/runtime": "^7.23.9",
"@types/prop-types": "^15.7.12",
+ "clsx": "^2.1.1",
"prop-types": "^15.8.1",
"react-is": "^18.3.1"
},
diff --git a/packages/mui-utils/src/appendOwnerState/appendOwnerState.spec.tsx b/packages/mui-utils/src/appendOwnerState/appendOwnerState.spec.tsx
new file mode 100644
index 00000000000000..2a6f571cc555b0
--- /dev/null
+++ b/packages/mui-utils/src/appendOwnerState/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-utils/src/appendOwnerState/appendOwnerState.test.ts b/packages/mui-utils/src/appendOwnerState/appendOwnerState.test.ts
new file mode 100644
index 00000000000000..fe4af6328836b6
--- /dev/null
+++ b/packages/mui-utils/src/appendOwnerState/appendOwnerState.test.ts
@@ -0,0 +1,67 @@
+import { expect } from 'chai';
+import appendOwnerState from '@mui/utils/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-utils/src/appendOwnerState/appendOwnerState.ts b/packages/mui-utils/src/appendOwnerState/appendOwnerState.ts
new file mode 100644
index 00000000000000..0c20bb3580cdfb
--- /dev/null
+++ b/packages/mui-utils/src/appendOwnerState/appendOwnerState.ts
@@ -0,0 +1,53 @@
+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
+ */
+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;
+}
+
+export default appendOwnerState;
diff --git a/packages/mui-utils/src/appendOwnerState/index.ts b/packages/mui-utils/src/appendOwnerState/index.ts
new file mode 100644
index 00000000000000..7960208c3e81e3
--- /dev/null
+++ b/packages/mui-utils/src/appendOwnerState/index.ts
@@ -0,0 +1,2 @@
+export { default } from './appendOwnerState';
+export type { AppendOwnerStateReturnType } from './appendOwnerState';
diff --git a/packages/mui-utils/src/extractEventHandlers/extractEventHandlers.test.ts b/packages/mui-utils/src/extractEventHandlers/extractEventHandlers.test.ts
new file mode 100644
index 00000000000000..065ebd021faeb3
--- /dev/null
+++ b/packages/mui-utils/src/extractEventHandlers/extractEventHandlers.test.ts
@@ -0,0 +1,43 @@
+import { expect } from 'chai';
+import extractEventHandlers from '@mui/utils/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-utils/src/extractEventHandlers/extractEventHandlers.ts b/packages/mui-utils/src/extractEventHandlers/extractEventHandlers.ts
new file mode 100644
index 00000000000000..cde63d769e7c5d
--- /dev/null
+++ b/packages/mui-utils/src/extractEventHandlers/extractEventHandlers.ts
@@ -0,0 +1,32 @@
+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.
+ */
+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;
+}
+
+export default extractEventHandlers;
diff --git a/packages/mui-utils/src/extractEventHandlers/index.ts b/packages/mui-utils/src/extractEventHandlers/index.ts
new file mode 100644
index 00000000000000..4623325fc24156
--- /dev/null
+++ b/packages/mui-utils/src/extractEventHandlers/index.ts
@@ -0,0 +1 @@
+export { default } from './extractEventHandlers';
diff --git a/packages/mui-utils/src/index.ts b/packages/mui-utils/src/index.ts
index 44d58f355cd99d..d4b0d45770f7f6 100644
--- a/packages/mui-utils/src/index.ts
+++ b/packages/mui-utils/src/index.ts
@@ -45,3 +45,8 @@ export * from './generateUtilityClass';
export { default as unstable_generateUtilityClasses } from './generateUtilityClasses';
export { default as unstable_ClassNameGenerator } from './ClassNameGenerator';
export { default as clamp } from './clamp';
+export { default as unstable_useSlotProps } from './useSlotProps';
+export type { UseSlotPropsParameters, UseSlotPropsResult } from './useSlotProps';
+export { default as unstable_resolveComponentProps } from './resolveComponentProps';
+export { default as unstable_extractEventHandlers } from './extractEventHandlers';
+export * from './types';
diff --git a/packages/mui-utils/src/isHostComponent/index.ts b/packages/mui-utils/src/isHostComponent/index.ts
new file mode 100644
index 00000000000000..6c692fd4e68547
--- /dev/null
+++ b/packages/mui-utils/src/isHostComponent/index.ts
@@ -0,0 +1 @@
+export { default } from './isHostComponent';
diff --git a/packages/mui-utils/src/isHostComponent/isHostComponent.ts b/packages/mui-utils/src/isHostComponent/isHostComponent.ts
new file mode 100644
index 00000000000000..f94464fe4e38a4
--- /dev/null
+++ b/packages/mui-utils/src/isHostComponent/isHostComponent.ts
@@ -0,0 +1,10 @@
+import * as React from 'react';
+
+/**
+ * Determines if a given element is a DOM element name (i.e. not a React component).
+ */
+function isHostComponent(element: React.ElementType) {
+ return typeof element === 'string';
+}
+
+export default isHostComponent;
diff --git a/packages/mui-utils/src/mergeSlotProps/index.ts b/packages/mui-utils/src/mergeSlotProps/index.ts
new file mode 100644
index 00000000000000..5c4d0cc59f400c
--- /dev/null
+++ b/packages/mui-utils/src/mergeSlotProps/index.ts
@@ -0,0 +1,7 @@
+export { default } from './mergeSlotProps';
+
+export type {
+ WithCommonProps,
+ MergeSlotPropsParameters,
+ MergeSlotPropsResult,
+} from './mergeSlotProps';
diff --git a/packages/mui-utils/src/mergeSlotProps/mergeSlotProps.test.ts b/packages/mui-utils/src/mergeSlotProps/mergeSlotProps.test.ts
new file mode 100644
index 00000000000000..ed0306822dfedc
--- /dev/null
+++ b/packages/mui-utils/src/mergeSlotProps/mergeSlotProps.test.ts
@@ -0,0 +1,192 @@
+import * as React from 'react';
+import { expect } from 'chai';
+import { EventHandlers } from '@mui/utils';
+import mergeSlotProps from '@mui/utils/mergeSlotProps';
+
+describe('mergeSlotProps', () => {
+ it('overrides the internal props with the external ones', () => {
+ const getSlotProps = () => ({
+ prop1: 'internal',
+ prop2: 'internal',
+ prop3: 'internal',
+ prop4: 'internal',
+ });
+
+ const additionalProps = {
+ prop1: 'additional',
+ prop2: 'additional',
+ prop3: 'additional',
+ };
+
+ const externalForwardedProps = {
+ prop1: 'externalForwarded',
+ prop2: 'externalForwarded',
+ };
+
+ const externalSlotProps = {
+ prop1: 'externalSlot',
+ };
+
+ const merged = mergeSlotProps({
+ getSlotProps,
+ additionalProps,
+ externalForwardedProps,
+ externalSlotProps,
+ });
+
+ expect(merged.props.prop1).to.equal('externalSlot');
+ expect(merged.props.prop2).to.equal('externalForwarded');
+ expect(merged.props.prop3).to.equal('additional');
+ expect(merged.props.prop4).to.equal('internal');
+ });
+
+ describe('it joins all class names in order from least to most important', () => {
+ it('when internal classNames from getSlotProps are included', () => {
+ const getSlotProps = () => ({
+ className: 'internal',
+ });
+
+ const additionalProps = {
+ className: 'additional',
+ };
+
+ const externalForwardedProps = {
+ className: 'externalForwarded',
+ };
+
+ const externalSlotProps = {
+ className: 'externalSlot',
+ };
+
+ const className = ['class1', 'class2'];
+
+ const merged = mergeSlotProps({
+ getSlotProps,
+ additionalProps,
+ externalForwardedProps,
+ externalSlotProps,
+ className,
+ });
+
+ expect(merged.props.className).to.equal(
+ 'internal additional class1 class2 externalForwarded externalSlot',
+ );
+ });
+
+ it('when getSlotProps is not present', () => {
+ const additionalProps = {
+ className: 'additional',
+ };
+
+ const externalForwardedProps = {
+ className: 'externalForwarded',
+ };
+
+ const externalSlotProps = {
+ className: 'externalSlot',
+ };
+
+ const className = ['class1', 'class2'];
+
+ const merged = mergeSlotProps({
+ additionalProps,
+ externalForwardedProps,
+ externalSlotProps,
+ className,
+ });
+
+ expect(merged.props.className).to.equal(
+ 'additional class1 class2 externalForwarded externalSlot',
+ );
+ });
+ });
+
+ it('merges the style props', () => {
+ const getSlotProps = () => ({
+ style: {
+ fontSize: '12px',
+ textAlign: 'center' as const,
+ },
+ });
+
+ const additionalProps = {
+ style: {
+ fontSize: '14px',
+ color: 'red',
+ },
+ };
+
+ const externalForwardedProps = {
+ style: {
+ fontWeight: 500,
+ },
+ };
+
+ const externalSlotProps = {
+ style: {
+ textDecoration: 'underline',
+ },
+ };
+
+ const merged = mergeSlotProps({
+ getSlotProps,
+ additionalProps,
+ externalForwardedProps,
+ externalSlotProps,
+ });
+
+ expect(merged.props.style).to.deep.equal({
+ textAlign: 'center',
+ color: 'red',
+ fontSize: '14px',
+ fontWeight: 500,
+ textDecoration: 'underline',
+ });
+ });
+
+ it('returns the ref returned from the getSlotProps function', () => {
+ const ref = React.createRef();
+ const getSlotProps = () => ({
+ ref,
+ });
+
+ const merged = mergeSlotProps({
+ getSlotProps,
+ });
+
+ expect(merged.internalRef).to.equal(ref);
+ });
+
+ it('does not require any parameters', () => {
+ const merged = mergeSlotProps({});
+ expect(merged.props).to.deep.equal({});
+ });
+
+ it('passes the external event handlers to the getSlotProps function (if defined)', () => {
+ const externalClickHandler = () => {};
+ const externalMouseOverHandler = () => {};
+
+ const getSlotProps = (eventHandlers: EventHandlers) => {
+ expect(eventHandlers.onClick).to.equal(externalClickHandler);
+ expect(eventHandlers.onMouseOver).to.equal(externalMouseOverHandler);
+ return {};
+ };
+
+ const externalForwardedProps = {
+ onClick: externalClickHandler,
+ };
+
+ const externalSlotProps = {
+ onMouseOver: externalMouseOverHandler,
+ };
+
+ const merged = mergeSlotProps({
+ getSlotProps,
+ externalForwardedProps,
+ externalSlotProps,
+ });
+
+ expect(Object.keys(merged.props)).not.to.contain('onClick');
+ expect(Object.keys(merged.props)).not.to.contain('onMouseOver');
+ });
+});
diff --git a/packages/mui-utils/src/mergeSlotProps/mergeSlotProps.ts b/packages/mui-utils/src/mergeSlotProps/mergeSlotProps.ts
new file mode 100644
index 00000000000000..0eff7c0e6673b1
--- /dev/null
+++ b/packages/mui-utils/src/mergeSlotProps/mergeSlotProps.ts
@@ -0,0 +1,184 @@
+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
+ */
+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,
+ };
+}
+
+export default mergeSlotProps;
diff --git a/packages/mui-utils/src/omitEventHandlers/index.ts b/packages/mui-utils/src/omitEventHandlers/index.ts
new file mode 100644
index 00000000000000..5c0402dfb1d8ef
--- /dev/null
+++ b/packages/mui-utils/src/omitEventHandlers/index.ts
@@ -0,0 +1 @@
+export { default } from './omitEventHandlers';
diff --git a/packages/mui-utils/src/omitEventHandlers/omitEventHandlers.test.ts b/packages/mui-utils/src/omitEventHandlers/omitEventHandlers.test.ts
new file mode 100644
index 00000000000000..138d82b9a948f3
--- /dev/null
+++ b/packages/mui-utils/src/omitEventHandlers/omitEventHandlers.test.ts
@@ -0,0 +1,29 @@
+import { expect } from 'chai';
+import omitEventHandlers from '@mui/utils/omitEventHandlers';
+
+describe('omitEventHandlers', () => {
+ it('should remove functions with names beginning with `on` followed by uppercase letter', () => {
+ const obj = {
+ onClick: () => {},
+ onKeyDown: () => {},
+ foo: 12,
+ bar: 'baz',
+ onion: {},
+ once: () => {},
+ on2: () => {},
+ on: () => {},
+ };
+
+ const result = omitEventHandlers(obj);
+
+ expect(result).to.haveOwnProperty('foo');
+ expect(result).to.haveOwnProperty('bar');
+ expect(result).to.haveOwnProperty('onion');
+ expect(result).to.haveOwnProperty('once');
+ expect(result).to.haveOwnProperty('on2');
+ expect(result).to.haveOwnProperty('on');
+
+ expect(result).to.not.haveOwnProperty('onClick');
+ expect(result).to.not.haveOwnProperty('onKeyDown');
+ });
+});
diff --git a/packages/mui-utils/src/omitEventHandlers/omitEventHandlers.ts b/packages/mui-utils/src/omitEventHandlers/omitEventHandlers.ts
new file mode 100644
index 00000000000000..f5dc4c7d908bee
--- /dev/null
+++ b/packages/mui-utils/src/omitEventHandlers/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.
+ */
+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;
+}
+
+export default omitEventHandlers;
diff --git a/packages/mui-utils/src/resolveComponentProps/index.ts b/packages/mui-utils/src/resolveComponentProps/index.ts
new file mode 100644
index 00000000000000..617ec3dc86ebdc
--- /dev/null
+++ b/packages/mui-utils/src/resolveComponentProps/index.ts
@@ -0,0 +1 @@
+export { default } from './resolveComponentProps';
diff --git a/packages/mui-utils/src/resolveComponentProps/resolveComponentProps.ts b/packages/mui-utils/src/resolveComponentProps/resolveComponentProps.ts
new file mode 100644
index 00000000000000..5a26a24b5f61db
--- /dev/null
+++ b/packages/mui-utils/src/resolveComponentProps/resolveComponentProps.ts
@@ -0,0 +1,23 @@
+/**
+ * If `componentProps` is a function, calls it with the provided `ownerState`.
+ * Otherwise, just returns `componentProps`.
+ */
+function resolveComponentProps