The Number Input component provides users with a field for integer values, and buttons to increment or decrement the value.
+
+{{"component": "modules/components/ComponentLinkHeader.js", "design": false}}
+
+{{"component": "modules/components/ComponentPageTabs.js"}}
+
+## Introduction
+
+A number input is a UI element that accepts numeric values from the user.
+Base UI's Number Input component is a customizable replacement for the native HTML `` containing the other interior slots
+- `input`: an `
` element
+- `incrementButton`: a `
` for increasing the value
+- `decrementButton`: a `` for decreasing the value
+
+```html
+
+
+
+
+
+```
+
+### Slot props
+
+:::info
+The following props are available on all non-utility Base components.
+See [Usage](/base-ui/getting-started/usage/) for full details.
+:::
+
+Use the `slots` prop to override the root slot or any interior slots:
+
+```jsx
+
+```
+
+Use the `slotProps` prop to pass custom props to internal slots.
+The following code snippet:
+
+- applies a CSS class called `my-num-input` to the input slot,
+- and passes a `direction` prop to the `CustomButton` components in the increment and decrement button slots
+
+```jsx
+
+```
+
+## Hook
+
+```js
+import useNumberInput from '@mui/base/unstable_useNumberInput';
+```
+
+The `useNumberInput` hook lets you apply the functionality of a number input to a fully custom component.
+It returns props to be placed on the custom component, along with fields representing the component's internal state.
+
+Hooks _do not_ support [slot props](#slot-props), but they do support [customization props](#customization).
+
+:::info
+Hooks give you the most room for customization, but require more work to implement.
+With hooks, you can take full control over how your component is rendered, and define all the custom props and CSS classes you need.
+
+You may not need to use hooks unless you find that you're limited by the customization options of their component counterparts—for instance, if your component requires significantly different [structure](#anatomy).
+:::
+
+Here's an example of a custom component built using the `useNumberInput` hook with all the required props:
+
+{{"demo": "UseNumberInput.js", "defaultCodeOpen": false}}
+
+Here's an example of a "compact" number input component using the hook that only consists of the stepper buttons.
+In this demo, `onChange` is used to write the latest value of the component to a state variable.
+
+{{"demo": "UseNumberInputCompact", "defaultCodeOpen": false}}
diff --git a/docs/data/base/pages.ts b/docs/data/base/pages.ts
index ac8a24e1875487..07c34afea6ba4f 100644
--- a/docs/data/base/pages.ts
+++ b/docs/data/base/pages.ts
@@ -25,6 +25,7 @@ const pages: readonly MuiPage[] = [
{ pathname: '/base-ui/react-button', title: 'Button' },
{ pathname: '/base-ui/react-checkbox', title: 'Checkbox', planned: true },
{ pathname: '/base-ui/react-input', title: 'Input' },
+ { pathname: '/base-ui/react-number-input', title: 'Number Input' },
{ pathname: '/base-ui/react-radio-button', title: 'Radio Button', planned: true },
{ pathname: '/base-ui/react-rating', title: 'Rating', planned: true },
{ pathname: '/base-ui/react-select', title: 'Select' },
diff --git a/docs/data/base/pagesApi.js b/docs/data/base/pagesApi.js
index 87fb37f2e44ef1..caf0be66448658 100644
--- a/docs/data/base/pagesApi.js
+++ b/docs/data/base/pagesApi.js
@@ -24,6 +24,10 @@ module.exports = [
{ pathname: '/base-ui/react-menu/components-api/#menu-item', title: 'MenuItem' },
{ pathname: '/base-ui/react-modal/components-api/#modal', title: 'Modal' },
{ pathname: '/base-ui/react-no-ssr/components-api/#no-ssr', title: 'NoSsr' },
+ {
+ pathname: '/base-ui/react-number-input/components-api/#number-input',
+ title: 'NumberInput',
+ },
{ pathname: '/base-ui/react-select/components-api/#option', title: 'Option' },
{
pathname: '/base-ui/react-select/components-api/#option-group',
@@ -68,6 +72,10 @@ module.exports = [
title: 'useMenuButton',
},
{ pathname: '/base-ui/react-menu/hooks-api/#use-menu-item', title: 'useMenuItem' },
+ {
+ pathname: '/base-ui/react-number-input/hooks-api/#use-number-input',
+ title: 'useNumberInput',
+ },
{ pathname: '/base-ui/react-select/hooks-api/#use-option', title: 'useOption' },
{ pathname: '/base-ui/react-select/hooks-api/#use-select', title: 'useSelect' },
{ pathname: '/base-ui/react-slider/hooks-api/#use-slider', title: 'useSlider' },
diff --git a/docs/pages/base-ui/api/number-input.json b/docs/pages/base-ui/api/number-input.json
new file mode 100644
index 00000000000000..ab15ad7756169b
--- /dev/null
+++ b/docs/pages/base-ui/api/number-input.json
@@ -0,0 +1,53 @@
+{
+ "props": {
+ "defaultValue": { "type": { "name": "any" } },
+ "disabled": { "type": { "name": "bool" } },
+ "error": { "type": { "name": "bool" } },
+ "id": { "type": { "name": "string" } },
+ "max": { "type": { "name": "number" } },
+ "min": { "type": { "name": "number" } },
+ "onChange": {
+ "type": { "name": "func" },
+ "signature": {
+ "type": "function(event: React.FocusEvent | React.PointerEvent | React.KeyboardEvent, value: number | undefined) => void",
+ "describedArgs": ["event", "value"]
+ }
+ },
+ "onInputChange": {
+ "type": { "name": "func" },
+ "signature": {
+ "type": "function(event: React.ChangeEvent) => void",
+ "describedArgs": ["event"]
+ }
+ },
+ "readOnly": { "type": { "name": "bool" }, "default": "false" },
+ "required": { "type": { "name": "bool" } },
+ "shiftMultiplier": { "type": { "name": "number" } },
+ "slotProps": {
+ "type": {
+ "name": "shape",
+ "description": "{ decrementButton?: func | object, incrementButton?: func | object, input?: func | object, root?: func | object }"
+ },
+ "default": "{}"
+ },
+ "slots": {
+ "type": {
+ "name": "shape",
+ "description": "{ decrementButton?: elementType, incrementButton?: elementType, input?: elementType, root?: elementType }"
+ },
+ "default": "{}",
+ "additionalPropsInfo": { "slotsApi": true }
+ },
+ "step": { "type": { "name": "number" } },
+ "value": { "type": { "name": "any" } }
+ },
+ "name": "NumberInput",
+ "styles": { "classes": [], "globalClasses": {}, "name": null },
+ "spread": true,
+ "muiName": "MuiNumberInput",
+ "forwardsRefTo": "HTMLDivElement",
+ "filename": "/packages/mui-base/src/Unstable_NumberInput/NumberInput.tsx",
+ "inheritance": null,
+ "demos": "",
+ "cssComponent": false
+}
diff --git a/docs/pages/base-ui/api/use-number-input.json b/docs/pages/base-ui/api/use-number-input.json
new file mode 100644
index 00000000000000..03508578d30d7e
--- /dev/null
+++ b/docs/pages/base-ui/api/use-number-input.json
@@ -0,0 +1,120 @@
+{
+ "parameters": {
+ "defaultValue": { "type": { "name": "unknown", "description": "unknown" } },
+ "disabled": { "type": { "name": "boolean", "description": "boolean" } },
+ "error": { "type": { "name": "boolean", "description": "boolean" } },
+ "inputId": { "type": { "name": "string", "description": "string" } },
+ "inputRef": {
+ "type": {
+ "name": "React.Ref<HTMLInputElement>",
+ "description": "React.Ref<HTMLInputElement>"
+ }
+ },
+ "max": { "type": { "name": "number", "description": "number" } },
+ "min": { "type": { "name": "number", "description": "number" } },
+ "onBlur": {
+ "type": {
+ "name": "(event?: React.FocusEvent) => void",
+ "description": "(event?: React.FocusEvent) => void"
+ }
+ },
+ "onChange": {
+ "type": {
+ "name": "(event: React.FocusEvent<HTMLInputElement> | React.PointerEvent | React.KeyboardEvent, value: number | undefined) => void",
+ "description": "(event: React.FocusEvent<HTMLInputElement> | React.PointerEvent | React.KeyboardEvent, value: number | undefined) => void"
+ }
+ },
+ "onClick": {
+ "type": { "name": "React.MouseEventHandler", "description": "React.MouseEventHandler" }
+ },
+ "onFocus": {
+ "type": { "name": "React.FocusEventHandler", "description": "React.FocusEventHandler" }
+ },
+ "onInputChange": {
+ "type": {
+ "name": "React.ChangeEventHandler<HTMLInputElement>",
+ "description": "React.ChangeEventHandler<HTMLInputElement>"
+ }
+ },
+ "readOnly": { "type": { "name": "boolean", "description": "boolean" }, "default": "false" },
+ "required": { "type": { "name": "boolean", "description": "boolean" } },
+ "shiftMultiplier": { "type": { "name": "number", "description": "number" } },
+ "step": { "type": { "name": "number", "description": "number" } },
+ "value": { "type": { "name": "unknown", "description": "unknown" } }
+ },
+ "returnValue": {
+ "disabled": {
+ "type": { "name": "boolean", "description": "boolean" },
+ "default": "false",
+ "required": true
+ },
+ "error": {
+ "type": { "name": "boolean", "description": "boolean" },
+ "default": "false",
+ "required": true
+ },
+ "focused": {
+ "type": { "name": "boolean", "description": "boolean" },
+ "default": "false",
+ "required": true
+ },
+ "formControlContext": {
+ "type": {
+ "name": "FormControlState | undefined",
+ "description": "FormControlState | undefined"
+ },
+ "required": true
+ },
+ "getDecrementButtonProps": {
+ "type": {
+ "name": "<TOther extends Record<string, any> = {}>(externalProps?: TOther) => UseNumberInputDecrementButtonSlotProps<TOther>",
+ "description": "<TOther extends Record<string, any> = {}>(externalProps?: TOther) => UseNumberInputDecrementButtonSlotProps<TOther>"
+ },
+ "required": true
+ },
+ "getIncrementButtonProps": {
+ "type": {
+ "name": "<TOther extends Record<string, any> = {}>(externalProps?: TOther) => UseNumberInputIncrementButtonSlotProps<TOther>",
+ "description": "<TOther extends Record<string, any> = {}>(externalProps?: TOther) => UseNumberInputIncrementButtonSlotProps<TOther>"
+ },
+ "required": true
+ },
+ "getInputProps": {
+ "type": {
+ "name": "<TOther extends Record<string, any> = {}>(externalProps?: TOther) => UseNumberInputInputSlotProps<TOther>",
+ "description": "<TOther extends Record<string, any> = {}>(externalProps?: TOther) => UseNumberInputInputSlotProps<TOther>"
+ },
+ "required": true
+ },
+ "getRootProps": {
+ "type": {
+ "name": "<TOther extends Record<string, any> = {}>(externalProps?: TOther) => UseNumberInputRootSlotProps<TOther>",
+ "description": "<TOther extends Record<string, any> = {}>(externalProps?: TOther) => UseNumberInputRootSlotProps<TOther>"
+ },
+ "required": true
+ },
+ "inputValue": {
+ "type": { "name": "string | undefined", "description": "string | undefined" },
+ "required": true
+ },
+ "isDecrementDisabled": {
+ "type": { "name": "boolean", "description": "boolean" },
+ "default": "false",
+ "required": true
+ },
+ "isIncrementDisabled": {
+ "type": { "name": "boolean", "description": "boolean" },
+ "default": "false",
+ "required": true
+ },
+ "required": {
+ "type": { "name": "boolean", "description": "boolean" },
+ "default": "false",
+ "required": true
+ },
+ "value": { "type": { "name": "unknown", "description": "unknown" }, "required": true }
+ },
+ "name": "useNumberInput",
+ "filename": "/packages/mui-base/src/unstable_useNumberInput/useNumberInput.ts",
+ "demos": ""
+}
diff --git a/docs/pages/base-ui/react-number-input/[docsTab]/index.js b/docs/pages/base-ui/react-number-input/[docsTab]/index.js
new file mode 100644
index 00000000000000..389fe3c553f5fb
--- /dev/null
+++ b/docs/pages/base-ui/react-number-input/[docsTab]/index.js
@@ -0,0 +1,48 @@
+import * as React from 'react';
+import MarkdownDocs from 'docs/src/modules/components/MarkdownDocsV2';
+import AppFrame from 'docs/src/modules/components/AppFrame';
+import * as pageProps from 'docs/data/base/components/number-input/number-input.md?@mui/markdown';
+import mapApiPageTranslations from 'docs/src/modules/utils/mapApiPageTranslations';
+import NumberInputApiJsonPageContent from '../../api/number-input.json';
+import useNumberInputApiJsonPageContent from '../../api/use-number-input.json';
+
+export default function Page(props) {
+ const { userLanguage, ...other } = props;
+ return ;
+}
+
+Page.getLayout = (page) => {
+ return {page} ;
+};
+
+export const getStaticPaths = () => {
+ return {
+ paths: [{ params: { docsTab: 'components-api' } }, { params: { docsTab: 'hooks-api' } }],
+ fallback: false, // can also be true or 'blocking'
+ };
+};
+
+export const getStaticProps = () => {
+ const NumberInputApiReq = require.context(
+ 'docs/translations/api-docs-base/number-input',
+ false,
+ /number-input.*.json$/,
+ );
+ const NumberInputApiDescriptions = mapApiPageTranslations(NumberInputApiReq);
+
+ const useNumberInputApiReq = require.context(
+ 'docs/translations/api-docs/use-number-input',
+ false,
+ /use-number-input.*.json$/,
+ );
+ const useNumberInputApiDescriptions = mapApiPageTranslations(useNumberInputApiReq);
+
+ return {
+ props: {
+ componentsApiDescriptions: { NumberInput: NumberInputApiDescriptions },
+ componentsApiPageContents: { NumberInput: NumberInputApiJsonPageContent },
+ hooksApiDescriptions: { useNumberInput: useNumberInputApiDescriptions },
+ hooksApiPageContents: { useNumberInput: useNumberInputApiJsonPageContent },
+ },
+ };
+};
diff --git a/docs/pages/base-ui/react-number-input/index.js b/docs/pages/base-ui/react-number-input/index.js
new file mode 100644
index 00000000000000..124fb94411844c
--- /dev/null
+++ b/docs/pages/base-ui/react-number-input/index.js
@@ -0,0 +1,13 @@
+import * as React from 'react';
+import MarkdownDocs from 'docs/src/modules/components/MarkdownDocsV2';
+import AppFrame from 'docs/src/modules/components/AppFrame';
+import * as pageProps from 'docs/data/base/components/number-input/number-input.md?@mui/markdown';
+
+export default function Page(props) {
+ const { userLanguage, ...other } = props;
+ return ;
+}
+
+Page.getLayout = (page) => {
+ return {page} ;
+};
diff --git a/docs/src/modules/components/ComponentsApiContent.js b/docs/src/modules/components/ComponentsApiContent.js
index 061c8537860e18..6671b57e4033f2 100644
--- a/docs/src/modules/components/ComponentsApiContent.js
+++ b/docs/src/modules/components/ComponentsApiContent.js
@@ -109,11 +109,30 @@ export default function ComponentsApiContent(props) {
slotGuideLink = '/base-ui/guides/overriding-component-structure/';
}
- const source = filename
- .replace(/\/packages\/mui(-(.+?))?\/src/, (match, dash, pkg) => `@mui/${pkg}`)
+ // convert paths like `/packages/mui-base/src/Input...` to `@mui/base/Input...`
+ const packageAndFilename = filename.replace(
+ /\/packages\/mui(-(.+?))?\/src/,
+ (match, dash, pkg) => `@mui/${pkg}`,
+ );
+
+ const source = packageAndFilename
// convert things like `/Table/Table.js` to ``
.replace(/\/([^/]+)\/\1\.(js|tsx)$/, '');
+ const defaultImportName = pageContent.name;
+ let defaultImportPath = `${source}/${defaultImportName}`;
+ let namedImportPath = source;
+ let namedImportName = defaultImportName;
+
+ if (/Unstable_/.test(source)) {
+ defaultImportPath = source.replace(/\/[^/]*$/, '');
+ namedImportPath = packageAndFilename
+ .replace(/Unstable_/, '')
+ .replace(/\/([^/]+)\/\1\.(js|tsx)$/, '');
+
+ namedImportName = `Unstable_${defaultImportName} as ${defaultImportName}`;
+ }
+
// The `ref` is forwarded to the root element.
let refHint = t('api-docs.refRootElement');
if (forwardsRefTo == null) {
@@ -146,9 +165,9 @@ export default function ComponentsApiContent(props) {
diff --git a/docs/src/modules/components/HooksApiContent.js b/docs/src/modules/components/HooksApiContent.js
index b8063707519649..3fef27c35dfaa2 100644
--- a/docs/src/modules/components/HooksApiContent.js
+++ b/docs/src/modules/components/HooksApiContent.js
@@ -63,6 +63,12 @@ export default function HooksApiContent(props) {
const hookNameKebabCase = kebabCase(hookName);
+ let defaultImportName = hookName;
+
+ if (/unstable_/.test(filename)) {
+ defaultImportName = `unstable_${hookName} as ${hookName}`;
+ }
+
return (
@@ -72,7 +78,7 @@ export default function HooksApiContent(props) {
code={`
import ${hookName} from '${source.split('/').slice(0, -1).join('/')}';
// ${t('or')}
-import { ${hookName} } from '${source.split('/').slice(0, 2).join('/')}';`}
+import { ${defaultImportName} } from '${source.split('/').slice(0, 2).join('/')}';`}
language="jsx"
/>
diff --git a/docs/translations/api-docs-base/number-input-unstyled/number-input-unstyled.json b/docs/translations/api-docs-base/number-input-unstyled/number-input-unstyled.json
new file mode 100644
index 00000000000000..4d8c3b82671253
--- /dev/null
+++ b/docs/translations/api-docs-base/number-input-unstyled/number-input-unstyled.json
@@ -0,0 +1,19 @@
+{
+ "componentDescription": "",
+ "propDescriptions": {
+ "component": "The component used for the root node. Either a string to use a HTML element or a component.",
+ "defaultValue": "The default value. Use when the component is not controlled.",
+ "disabled": "If true
, the component is disabled. The prop defaults to the value (false
) inherited from the parent FormControl component.",
+ "error": "If true
, the input
will indicate an error by setting the aria-invalid
attribute on the input and the Mui-error
class on the root element.",
+ "id": "The id of the input
element.",
+ "max": "The maximum value.",
+ "min": "The minimum value.",
+ "onValueChange": "Callback fired after the value is clamped and changes. Called with undefined
when the value is unset.",
+ "required": "If true
, the input
element is required. The prop defaults to the value (false
) inherited from the parent FormControl component.",
+ "slotProps": "The props used for each slot inside the NumberInput.",
+ "slots": "The components used for each slot inside the InputBase. Either a string to use a HTML element or a component. See Slots API below for more details.",
+ "step": "The amount that the value changes on each increment or decrement.",
+ "value": "The current value. Use when the component is controlled."
+ },
+ "classDescriptions": {}
+}
diff --git a/docs/translations/api-docs-base/number-input/number-input.json b/docs/translations/api-docs-base/number-input/number-input.json
new file mode 100644
index 00000000000000..b588e42ddadeb0
--- /dev/null
+++ b/docs/translations/api-docs-base/number-input/number-input.json
@@ -0,0 +1,44 @@
+{
+ "componentDescription": "",
+ "propDescriptions": {
+ "defaultValue": {
+ "description": "The default value. Use when the component is not controlled."
+ },
+ "disabled": {
+ "description": "If true
, the component is disabled. The prop defaults to the value (false
) inherited from the parent FormControl component."
+ },
+ "error": {
+ "description": "If true
, the input
will indicate an error by setting the aria-invalid
attribute on the input and the Mui-error
class on the root element."
+ },
+ "id": { "description": "The id of the input
element." },
+ "max": { "description": "The maximum value." },
+ "min": { "description": "The minimum value." },
+ "onChange": {
+ "description": "Callback fired after the value is clamped and changes - when the input
is blurred or when the stepper buttons are triggered. Called with undefined
when the value is unset.",
+ "typeDescriptions": {
+ "event": "The event source of the callback",
+ "value": "The new value of the component"
+ }
+ },
+ "onInputChange": {
+ "description": "Callback fired when the input
value changes after each keypress, before clamping is applied. Note that event.target.value
may contain values that fall outside of min
and max
or are otherwise "invalid".",
+ "typeDescriptions": { "event": "The event source of the callback." }
+ },
+ "readOnly": {
+ "description": "If true
, the input
element becomes read-only. The stepper buttons remain active, with the addition that they are now keyboard focusable."
+ },
+ "required": {
+ "description": "If true
, the input
element is required. The prop defaults to the value (false
) inherited from the parent FormControl component."
+ },
+ "shiftMultiplier": {
+ "description": "Multiplier applied to step
if the shift key is held while incrementing or decrementing the value. Defaults to 10
."
+ },
+ "slotProps": { "description": "The props used for each slot inside the NumberInput." },
+ "slots": {
+ "description": "The components used for each slot inside the InputBase. Either a string to use a HTML element or a component."
+ },
+ "step": { "description": "The amount that the value changes on each increment or decrement." },
+ "value": { "description": "The current value. Use when the component is controlled." }
+ },
+ "classDescriptions": {}
+}
diff --git a/docs/translations/api-docs/use-number-input/use-number-input.json b/docs/translations/api-docs/use-number-input/use-number-input.json
new file mode 100644
index 00000000000000..575cb5d734d751
--- /dev/null
+++ b/docs/translations/api-docs/use-number-input/use-number-input.json
@@ -0,0 +1,66 @@
+{
+ "hookDescription": "",
+ "parametersDescriptions": {
+ "defaultValue": {
+ "description": "The default value. Use when the component is not controlled."
+ },
+ "disabled": {
+ "description": "If true
, the component is disabled.\nThe prop defaults to the value (false
) inherited from the parent FormControl component."
+ },
+ "error": {
+ "description": "If true
, the input
will indicate an error by setting the aria-invalid
attribute.\nThe prop defaults to the value (false
) inherited from the parent FormControl component."
+ },
+ "inputId": { "description": "The id
attribute of the input element." },
+ "inputRef": { "description": "The ref of the input element." },
+ "max": { "description": "The maximum value." },
+ "min": { "description": "The minimum value." },
+ "onChange": {
+ "description": "Callback fired after the value is clamped and changes - when the input
is blurred or when\nthe stepper buttons are triggered.\nCalled with undefined
when the value is unset."
+ },
+ "onInputChange": {
+ "description": "Callback fired when the input
value changes after each keypress, before clamping is applied.\nNote that event.target.value
may contain values that fall outside of min
and max
or\nare otherwise "invalid"."
+ },
+ "readOnly": {
+ "description": "If true
, the input
element becomes read-only. The stepper buttons remain active,\nwith the addition that they are now keyboard focusable."
+ },
+ "required": {
+ "description": "If true
, the input
element is required.\nThe prop defaults to the value (false
) inherited from the parent FormControl component."
+ },
+ "shiftMultiplier": {
+ "description": "Multiplier applied to step
if the shift key is held while incrementing\nor decrementing the value. Defaults to 10
."
+ },
+ "step": { "description": "The amount that the value changes on each increment or decrement." },
+ "value": { "description": "The current value. Use when the component is controlled." }
+ },
+ "returnValueDescriptions": {
+ "disabled": { "description": "If true
, the component will be disabled." },
+ "error": {
+ "description": "If true
, the input
will indicate an error by setting the aria-invalid
attribute."
+ },
+ "focused": { "description": "If true
, the input
will be focused." },
+ "formControlContext": {
+ "description": "Return value from the useFormControlContext
hook."
+ },
+ "getDecrementButtonProps": {
+ "description": "Resolver for the decrement button slot's props."
+ },
+ "getIncrementButtonProps": {
+ "description": "Resolver for the increment button slot's props."
+ },
+ "getInputProps": { "description": "Resolver for the input slot's props." },
+ "getRootProps": { "description": "Resolver for the root slot's props." },
+ "inputValue": {
+ "description": "The dirty value
of the input
element when it is in focus."
+ },
+ "isDecrementDisabled": {
+ "description": "If true
, the decrement button will be disabled.\ne.g. when the value
is already at min
"
+ },
+ "isIncrementDisabled": {
+ "description": "If true
, the increment button will be disabled.\ne.g. when the value
is already at max
"
+ },
+ "required": {
+ "description": "If true
, the input
will indicate that it's required."
+ },
+ "value": { "description": "The clamped value
of the input
element." }
+ }
+}
diff --git a/docs/translations/translations.json b/docs/translations/translations.json
index e4cdd4d1e8ef6f..8e574f1b2f6c19 100644
--- a/docs/translations/translations.json
+++ b/docs/translations/translations.json
@@ -237,6 +237,7 @@
"/base-ui/react-button": "Button",
"/base-ui/react-checkbox": "Checkbox",
"/base-ui/react-input": "Input",
+ "/base-ui/react-number-input": "Number Input",
"/base-ui/react-radio-button": "Radio Button",
"/base-ui/react-rating": "Rating",
"/base-ui/react-select": "Select",
@@ -277,6 +278,7 @@
"/base-ui/react-menu/components-api/#menu-item": "MenuItem",
"/base-ui/react-modal/components-api/#modal": "Modal",
"/base-ui/react-no-ssr/components-api/#no-ssr": "NoSsr",
+ "/base-ui/react-number-input/components-api/#number-input": "NumberInput",
"/base-ui/react-select/components-api/#option": "Option",
"/base-ui/react-select/components-api/#option-group": "OptionGroup",
"/base-ui/react-popper/components-api/#popper": "Popper",
@@ -300,6 +302,7 @@
"/base-ui/react-menu/hooks-api/#use-menu": "useMenu",
"/base-ui/react-menu/hooks-api/#use-menu-button": "useMenuButton",
"/base-ui/react-menu/hooks-api/#use-menu-item": "useMenuItem",
+ "/base-ui/react-number-input/hooks-api/#use-number-input": "useNumberInput",
"/base-ui/react-select/hooks-api/#use-option": "useOption",
"/base-ui/react-select/hooks-api/#use-select": "useSelect",
"/base-ui/react-slider/hooks-api/#use-slider": "useSlider",
diff --git a/package.json b/package.json
index ebe6061728deb2..728f0121e3c8e3 100644
--- a/package.json
+++ b/package.json
@@ -97,6 +97,7 @@
"@slack/bolt": "^3.13.2",
"@testing-library/dom": "^9.3.1",
"@testing-library/react": "^14.0.0",
+ "@testing-library/user-event": "^14.4.3",
"@types/chai": "^4.3.5",
"@types/chai-dom": "^1.11.0",
"@types/enzyme": "^3.10.13",
diff --git a/packages/mui-base/src/Unstable_NumberInput/NumberInput.test.tsx b/packages/mui-base/src/Unstable_NumberInput/NumberInput.test.tsx
new file mode 100644
index 00000000000000..fe8bb2f8b69c54
--- /dev/null
+++ b/packages/mui-base/src/Unstable_NumberInput/NumberInput.test.tsx
@@ -0,0 +1,436 @@
+import * as React from 'react';
+import { expect } from 'chai';
+import { spy } from 'sinon';
+import userEvent from '@testing-library/user-event';
+import { act, createMount, createRenderer, describeConformanceUnstyled } from 'test/utils';
+import NumberInput, {
+ numberInputClasses,
+ NumberInputOwnerState,
+} from '@mui/base/Unstable_NumberInput';
+
+describe(' ', () => {
+ const mount = createMount();
+ const { render } = createRenderer();
+
+ describeConformanceUnstyled( , () => ({
+ inheritComponent: 'div',
+ render,
+ mount,
+ refInstanceof: window.HTMLDivElement,
+ testComponentPropWith: 'div',
+ muiName: 'MuiNumberInput',
+ slots: {
+ root: {
+ expectedClassName: numberInputClasses.root,
+ },
+ input: {
+ expectedClassName: numberInputClasses.input,
+ testWithElement: 'input',
+ },
+ incrementButton: {
+ expectedClassName: numberInputClasses.incrementButton,
+ testWithElement: 'button',
+ },
+ decrementButton: {
+ expectedClassName: numberInputClasses.decrementButton,
+ testWithElement: 'button',
+ },
+ },
+ skip: ['componentProp'],
+ }));
+
+ it('should be able to attach input ref passed through props', () => {
+ const inputRef = React.createRef();
+ const { getByTestId } = render(
+ ,
+ );
+
+ expect(inputRef.current).to.deep.equal(getByTestId('input'));
+ });
+
+ it('passes ownerState to all the slots', () => {
+ interface SlotProps {
+ ownerState: NumberInputOwnerState;
+ children?: React.ReactNode;
+ }
+
+ const CustomComponent = React.forwardRef(
+ ({ ownerState, children }: SlotProps, ref: React.Ref) => {
+ return (
+
+ {children}
+
+ );
+ },
+ );
+
+ const slots = {
+ root: CustomComponent,
+ input: CustomComponent,
+ decrementButton: CustomComponent,
+ incrementButton: CustomComponent,
+ };
+
+ const { getAllByTestId } = render( );
+ const renderedComponents = getAllByTestId('custom');
+
+ expect(renderedComponents.length).to.equal(4);
+ for (let i = 0; i < renderedComponents.length; i += 1) {
+ expect(renderedComponents[i]).to.have.attribute('data-disabled', 'true');
+ expect(renderedComponents[i]).to.have.attribute('data-focused', 'false');
+ expect(renderedComponents[i]).to.have.attribute('data-readonly', 'true');
+ expect(renderedComponents[i]).to.have.attribute('data-decrementdisabled', 'true');
+ expect(renderedComponents[i]).to.have.attribute('data-incrementdisabled', 'true');
+ }
+ });
+
+ describe('step buttons', () => {
+ it('clicking the increment and decrement buttons changes the value', async () => {
+ const user = userEvent.setup();
+ const handleChange = spy();
+
+ const { getByTestId } = render(
+ ,
+ );
+
+ const input = getByTestId('input') as HTMLInputElement;
+ const incrementButton = getByTestId('increment-btn');
+ const decrementButton = getByTestId('decrement-btn');
+
+ await user.click(incrementButton);
+ expect(handleChange.args[0][1]).to.equal(11);
+ expect(input.value).to.equal('11');
+
+ await user.click(decrementButton);
+ await user.click(decrementButton);
+ expect(handleChange.callCount).to.equal(3);
+ expect(handleChange.args[2][1]).to.equal(9);
+ expect(input.value).to.equal('9');
+ });
+
+ it('clicking the increment and decrement buttons changes the value based on shiftMultiplier if the Shift key is held', async () => {
+ const user = userEvent.setup();
+ const handleChange = spy();
+
+ const { getByTestId } = render(
+ ,
+ );
+
+ const input = getByTestId('input') as HTMLInputElement;
+ const incrementButton = getByTestId('increment-btn');
+ const decrementButton = getByTestId('decrement-btn');
+
+ // press Shift key without releasing it
+ await user.keyboard('{Shift>}');
+ await user.click(incrementButton);
+ await user.click(incrementButton);
+ expect(handleChange.args[1][1]).to.equal(30);
+ expect(input.value).to.equal('30');
+
+ await user.click(decrementButton);
+ expect(handleChange.args[2][1]).to.equal(25);
+ expect(handleChange.callCount).to.equal(3);
+ expect(input.value).to.equal('25');
+ });
+
+ it('clicking on the stepper buttons will focus the input', async () => {
+ const user = userEvent.setup();
+
+ const { getByTestId } = render(
+ ,
+ );
+
+ const input = getByTestId('input') as HTMLInputElement;
+ const incrementButton = getByTestId('increment-btn');
+ const decrementButton = getByTestId('decrement-btn');
+
+ expect(document.activeElement).to.equal(document.body);
+
+ await user.click(incrementButton);
+
+ expect(document.activeElement).to.equal(input);
+
+ act(() => {
+ input.blur();
+ });
+
+ expect(document.activeElement).to.equal(document.body);
+
+ await user.click(decrementButton);
+
+ expect(document.activeElement).to.equal(input);
+ });
+ });
+
+ describe('keyboard interaction', () => {
+ it('ArrowUp and ArrowDown changes the value', async () => {
+ const user = userEvent.setup();
+ const handleChange = spy();
+
+ const { getByTestId } = render(
+ ,
+ );
+
+ const input = getByTestId('input') as HTMLInputElement;
+
+ await user.click(input);
+
+ await user.keyboard('[ArrowUp]');
+ await user.keyboard('[ArrowUp]');
+ expect(handleChange.callCount).to.equal(2);
+ expect(handleChange.args[1][1]).to.equal(12);
+ expect(input.value).to.equal('12');
+
+ await user.keyboard('[ArrowDown]');
+ expect(handleChange.callCount).to.equal(3);
+ expect(handleChange.args[2][1]).to.equal(11);
+ expect(input.value).to.equal('11');
+ });
+
+ it('ArrowUp and ArrowDown changes the value based on a custom step', async () => {
+ const user = userEvent.setup();
+ const handleChange = spy();
+
+ const { getByTestId } = render(
+ ,
+ );
+
+ const input = getByTestId('input') as HTMLInputElement;
+
+ await user.click(input);
+
+ await user.keyboard('[ArrowUp]');
+ await user.keyboard('[ArrowUp]');
+ expect(handleChange.args[1][1]).to.equal(20);
+ expect(input.value).to.equal('20');
+
+ await user.keyboard('[ArrowDown]');
+ expect(handleChange.args[2][1]).to.equal(15);
+ expect(handleChange.callCount).to.equal(3);
+ expect(input.value).to.equal('15');
+ });
+
+ it('ArrowUp and ArrowDown changes the value based on shiftMultiplier if the Shift key is held', async () => {
+ const user = userEvent.setup();
+ const handleChange = spy();
+
+ const { getByTestId } = render(
+ ,
+ );
+
+ const input = getByTestId('input') as HTMLInputElement;
+
+ await user.click(input);
+
+ await user.keyboard('{Shift>}[ArrowUp]/');
+ expect(handleChange.callCount).to.equal(1);
+ expect(handleChange.args[0][1]).to.equal(25);
+ expect(input.value).to.equal('25');
+
+ await user.keyboard('{Shift>}[ArrowDown][ArrowDown]{/Shift}');
+ expect(handleChange.args[2][1]).to.equal(15);
+ expect(handleChange.callCount).to.equal(3);
+ expect(input.value).to.equal('15');
+ });
+
+ it('PageUp and PageDown changes the value based on shiftMultiplier', async () => {
+ const user = userEvent.setup();
+ const handleChange = spy();
+
+ const { getByTestId } = render(
+ ,
+ );
+
+ const input = getByTestId('input') as HTMLInputElement;
+
+ await user.click(input);
+
+ await user.keyboard('[PageUp]');
+ expect(handleChange.args[0][1]).to.equal(25);
+ expect(input.value).to.equal('25');
+
+ await user.keyboard('[PageDown][PageDown]');
+ expect(handleChange.args[2][1]).to.equal(15);
+ expect(handleChange.callCount).to.equal(3);
+ expect(input.value).to.equal('15');
+ });
+
+ it('sets value to max when Home is pressed', async () => {
+ const user = userEvent.setup();
+ const handleChange = spy();
+
+ const { getByTestId } = render(
+ ,
+ );
+
+ const input = getByTestId('input') as HTMLInputElement;
+
+ await user.click(input);
+
+ await user.keyboard('[Home]');
+ expect(handleChange.args[0][1]).to.equal(50);
+ expect(input.value).to.equal('50');
+ });
+
+ it('sets value to min when End is pressed', async () => {
+ const user = userEvent.setup();
+ const handleChange = spy();
+
+ const { getByTestId } = render(
+ ,
+ );
+
+ const input = getByTestId('input') as HTMLInputElement;
+
+ await user.click(input);
+
+ await user.keyboard('[End]');
+ expect(handleChange.args[0][1]).to.equal(1);
+ expect(input.value).to.equal('1');
+ });
+
+ it('sets value to min when the input has no value and ArrowUp is pressed', async () => {
+ const user = userEvent.setup();
+ const handleChange = spy();
+
+ const { getByTestId } = render(
+ ,
+ );
+
+ const input = getByTestId('input') as HTMLInputElement;
+
+ await user.click(input);
+
+ await user.keyboard('[ArrowUp]');
+ expect(handleChange.args[0][1]).to.equal(5);
+ expect(input.value).to.equal('5');
+ });
+
+ it('sets value to max when the input has no value and ArrowDown is pressed', async () => {
+ const user = userEvent.setup();
+ const handleChange = spy();
+
+ const { getByTestId } = render(
+ ,
+ );
+
+ const input = getByTestId('input') as HTMLInputElement;
+
+ await user.click(input);
+
+ await user.keyboard('[ArrowDown]');
+ expect(handleChange.args[0][1]).to.equal(9);
+ expect(input.value).to.equal('9');
+ });
+
+ it('only includes the input element in the tab order', async () => {
+ const user = userEvent.setup();
+
+ const { getByTestId } = render(
+ ,
+ );
+
+ const input = getByTestId('input') as HTMLInputElement;
+ expect(document.activeElement).to.equal(document.body);
+
+ await user.keyboard('[Tab]');
+ expect(document.activeElement).to.equal(input);
+
+ await user.keyboard('[Tab]');
+ expect(document.activeElement).to.equal(document.body);
+ });
+ });
+});
diff --git a/packages/mui-base/src/Unstable_NumberInput/NumberInput.tsx b/packages/mui-base/src/Unstable_NumberInput/NumberInput.tsx
new file mode 100644
index 00000000000000..3d540dc6eb2c41
--- /dev/null
+++ b/packages/mui-base/src/Unstable_NumberInput/NumberInput.tsx
@@ -0,0 +1,297 @@
+'use client';
+import * as React from 'react';
+import PropTypes from 'prop-types';
+import { OverridableComponent } from '@mui/types';
+import { getNumberInputUtilityClass } from './numberInputClasses';
+import useNumberInput from '../unstable_useNumberInput';
+import {
+ NumberInputOwnerState,
+ NumberInputProps,
+ NumberInputRootSlotProps,
+ NumberInputInputSlotProps,
+ NumberInputIncrementButtonSlotProps,
+ NumberInputDecrementButtonSlotProps,
+ NumberInputTypeMap,
+} from './NumberInput.types';
+import composeClasses from '../composeClasses';
+import { EventHandlers, useSlotProps, WithOptionalOwnerState } from '../utils';
+import { useClassNamesOverride } from '../utils/ClassNameConfigurator';
+
+const useUtilityClasses = (ownerState: NumberInputOwnerState) => {
+ const {
+ disabled,
+ error,
+ focused,
+ readOnly,
+ formControlContext,
+ isIncrementDisabled,
+ isDecrementDisabled,
+ } = ownerState;
+
+ const slots = {
+ root: [
+ 'root',
+ disabled && 'disabled',
+ error && 'error',
+ focused && 'focused',
+ readOnly && 'readOnly',
+ Boolean(formControlContext) && 'formControl',
+ ],
+ input: ['input', disabled && 'disabled', readOnly && 'readOnly'],
+ incrementButton: ['incrementButton', isIncrementDisabled && 'disabled'],
+ decrementButton: ['decrementButton', isDecrementDisabled && 'disabled'],
+ };
+
+ return composeClasses(slots, useClassNamesOverride(getNumberInputUtilityClass));
+};
+
+/**
+ *
+ * Demos:
+ *
+ * - [Number Input](https://mui.com/base-ui/react-number-input/)
+ *
+ * API:
+ *
+ * - [NumberInput API](https://mui.com/base-ui/react-number-input/components-api/#number-input)
+ */
+const NumberInput = React.forwardRef(function NumberInput(
+ props: NumberInputProps,
+ forwardedRef: React.ForwardedRef,
+) {
+ const {
+ className,
+ defaultValue,
+ disabled,
+ error,
+ id,
+ max,
+ min,
+ onBlur,
+ onInputChange,
+ onFocus,
+ onChange,
+ placeholder,
+ required,
+ readOnly = false,
+ shiftMultiplier,
+ step,
+ value,
+ slotProps = {},
+ slots = {},
+ ...rest
+ } = props;
+
+ const {
+ getRootProps,
+ getInputProps,
+ getIncrementButtonProps,
+ getDecrementButtonProps,
+ focused,
+ error: errorState,
+ disabled: disabledState,
+ formControlContext,
+ isIncrementDisabled,
+ isDecrementDisabled,
+ } = useNumberInput({
+ min,
+ max,
+ step,
+ shiftMultiplier,
+ defaultValue,
+ disabled,
+ error,
+ onFocus,
+ onInputChange,
+ onBlur,
+ onChange,
+ required,
+ readOnly,
+ value,
+ inputId: id,
+ });
+
+ const ownerState: NumberInputOwnerState = {
+ ...props,
+ disabled: disabledState,
+ error: errorState,
+ focused,
+ readOnly,
+ formControlContext,
+ isIncrementDisabled,
+ isDecrementDisabled,
+ };
+
+ const classes = useUtilityClasses(ownerState);
+
+ const propsForwardedToInputSlot = {
+ placeholder,
+ };
+
+ const Root = slots.root ?? 'div';
+ const rootProps: WithOptionalOwnerState = useSlotProps({
+ elementType: Root,
+ getSlotProps: getRootProps,
+ externalSlotProps: slotProps.root,
+ externalForwardedProps: rest,
+ additionalProps: {
+ ref: forwardedRef,
+ },
+ ownerState,
+ className: [classes.root, className],
+ });
+
+ const Input = slots.input ?? 'input';
+ const inputProps: WithOptionalOwnerState = useSlotProps({
+ elementType: Input,
+ getSlotProps: (otherHandlers: EventHandlers) =>
+ getInputProps({ ...otherHandlers, ...propsForwardedToInputSlot }),
+ externalSlotProps: slotProps.input,
+ ownerState,
+ className: classes.input,
+ });
+
+ const IncrementButton = slots.incrementButton ?? 'button';
+ const incrementButtonProps: WithOptionalOwnerState =
+ useSlotProps({
+ elementType: IncrementButton,
+ getSlotProps: getIncrementButtonProps,
+ externalSlotProps: slotProps.incrementButton,
+ ownerState,
+ className: classes.incrementButton,
+ });
+
+ const DecrementButton = slots.decrementButton ?? 'button';
+ const decrementButtonProps: WithOptionalOwnerState =
+ useSlotProps({
+ elementType: DecrementButton,
+ getSlotProps: getDecrementButtonProps,
+ externalSlotProps: slotProps.decrementButton,
+ ownerState,
+ className: classes.decrementButton,
+ });
+
+ return (
+
+
+
+
+
+ );
+}) as OverridableComponent;
+
+NumberInput.propTypes /* remove-proptypes */ = {
+ // ----------------------------- Warning --------------------------------
+ // | These PropTypes are generated from the TypeScript type definitions |
+ // | To update them edit TypeScript types and run "yarn proptypes" |
+ // ----------------------------------------------------------------------
+ /**
+ * @ignore
+ */
+ children: PropTypes.node,
+ /**
+ * @ignore
+ */
+ className: PropTypes.string,
+ /**
+ * The default value. Use when the component is not controlled.
+ */
+ defaultValue: PropTypes.any,
+ /**
+ * If `true`, the component is disabled.
+ * The prop defaults to the value (`false`) inherited from the parent FormControl component.
+ */
+ disabled: PropTypes.bool,
+ /**
+ * If `true`, the `input` will indicate an error by setting the `aria-invalid` attribute on the input and the `Mui-error` class on the root element.
+ */
+ error: PropTypes.bool,
+ /**
+ * The id of the `input` element.
+ */
+ id: PropTypes.string,
+ /**
+ * The maximum value.
+ */
+ max: PropTypes.number,
+ /**
+ * The minimum value.
+ */
+ min: PropTypes.number,
+ /**
+ * @ignore
+ */
+ onBlur: PropTypes.func,
+ /**
+ * Callback fired after the value is clamped and changes - when the `input` is blurred or when
+ * the stepper buttons are triggered.
+ * Called with `undefined` when the value is unset.
+ *
+ * @param {React.FocusEvent|React.PointerEvent|React.KeyboardEvent} event The event source of the callback
+ * @param {number|undefined} value The new value of the component
+ */
+ onChange: PropTypes.func,
+ /**
+ * @ignore
+ */
+ onFocus: PropTypes.func,
+ /**
+ * Callback fired when the `input` value changes after each keypress, before clamping is applied.
+ * Note that `event.target.value` may contain values that fall outside of `min` and `max` or
+ * are otherwise "invalid".
+ *
+ * @param {React.ChangeEvent} event The event source of the callback.
+ */
+ onInputChange: PropTypes.func,
+ /**
+ * @ignore
+ */
+ placeholder: PropTypes.string,
+ /**
+ * If `true`, the `input` element becomes read-only. The stepper buttons remain active,
+ * with the addition that they are now keyboard focusable.
+ * @default false
+ */
+ readOnly: PropTypes.bool,
+ /**
+ * If `true`, the `input` element is required.
+ * The prop defaults to the value (`false`) inherited from the parent FormControl component.
+ */
+ required: PropTypes.bool,
+ /**
+ * Multiplier applied to `step` if the shift key is held while incrementing
+ * or decrementing the value. Defaults to `10`.
+ */
+ shiftMultiplier: PropTypes.number,
+ /**
+ * The props used for each slot inside the NumberInput.
+ * @default {}
+ */
+ slotProps: PropTypes.shape({
+ decrementButton: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
+ incrementButton: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
+ input: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
+ root: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
+ }),
+ /**
+ * The components used for each slot inside the InputBase.
+ * Either a string to use a HTML element or a component.
+ * @default {}
+ */
+ slots: PropTypes.shape({
+ decrementButton: PropTypes.elementType,
+ incrementButton: PropTypes.elementType,
+ input: PropTypes.elementType,
+ root: PropTypes.elementType,
+ }),
+ /**
+ * The amount that the value changes on each increment or decrement.
+ */
+ step: PropTypes.number,
+ /**
+ * The current value. Use when the component is controlled.
+ */
+ value: PropTypes.any,
+} as any;
+
+export default NumberInput;
diff --git a/packages/mui-base/src/Unstable_NumberInput/NumberInput.types.ts b/packages/mui-base/src/Unstable_NumberInput/NumberInput.types.ts
new file mode 100644
index 00000000000000..513ba18a0a3b1d
--- /dev/null
+++ b/packages/mui-base/src/Unstable_NumberInput/NumberInput.types.ts
@@ -0,0 +1,104 @@
+import { Simplify } from '@mui/types';
+import { FormControlState } from '../FormControl';
+import {
+ UseNumberInputParameters,
+ UseNumberInputRootSlotProps,
+ UseNumberInputIncrementButtonSlotProps,
+ UseNumberInputDecrementButtonSlotProps,
+} from '../unstable_useNumberInput/useNumberInput.types';
+import { PolymorphicProps, SlotComponentProps } from '../utils';
+
+export interface NumberInputRootSlotPropsOverrides {}
+export interface NumberInputInputSlotPropsOverrides {}
+export interface NumberInputStepperButtonSlotPropsOverrides {}
+
+export type NumberInputOwnProps = Omit & {
+ /**
+ * If `true`, the `input` will indicate an error by setting the `aria-invalid` attribute on the input and the `Mui-error` class on the root element.
+ */
+ error?: boolean;
+ /**
+ * The id of the `input` element.
+ */
+ id?: string;
+ /**
+ * The props used for each slot inside the NumberInput.
+ * @default {}
+ */
+ slotProps?: {
+ root?: SlotComponentProps<'div', NumberInputRootSlotPropsOverrides, NumberInputOwnerState>;
+ input?: SlotComponentProps<'input', NumberInputInputSlotPropsOverrides, NumberInputOwnerState>;
+ incrementButton?: SlotComponentProps<
+ 'button',
+ NumberInputStepperButtonSlotPropsOverrides,
+ NumberInputOwnerState
+ >;
+ decrementButton?: SlotComponentProps<
+ 'button',
+ NumberInputStepperButtonSlotPropsOverrides,
+ NumberInputOwnerState
+ >;
+ };
+ /**
+ * The components used for each slot inside the InputBase.
+ * Either a string to use a HTML element or a component.
+ * @default {}
+ */
+ slots?: {
+ root?: React.ElementType;
+ input?: React.ElementType;
+ incrementButton?: React.ElementType;
+ decrementButton?: React.ElementType;
+ };
+};
+
+export interface NumberInputTypeMap<
+ AdditionalProps = {},
+ RootComponentType extends React.ElementType = 'div',
+> {
+ props: AdditionalProps & NumberInputOwnProps;
+ defaultComponent: RootComponentType;
+}
+
+export type NumberInputProps<
+ RootComponentType extends React.ElementType = NumberInputTypeMap['defaultComponent'],
+> = PolymorphicProps, RootComponentType>;
+
+export type NumberInputOwnerState = Simplify<
+ NumberInputOwnProps & {
+ formControlContext: FormControlState | undefined;
+ focused: boolean;
+ isIncrementDisabled: boolean;
+ isDecrementDisabled: boolean;
+ }
+>;
+
+export type NumberInputRootSlotProps = Simplify<
+ UseNumberInputRootSlotProps & {
+ ownerState: NumberInputOwnerState;
+ className?: string;
+ children?: React.ReactNode;
+ ref?: React.Ref;
+ }
+>;
+
+export type NumberInputInputSlotProps = Simplify<
+ Omit & {
+ id: string | undefined;
+ ownerState: NumberInputOwnerState;
+ placeholder: string | undefined;
+ ref: React.Ref;
+ }
+>;
+
+export type NumberInputIncrementButtonSlotProps = Simplify<
+ UseNumberInputIncrementButtonSlotProps & {
+ ownerState: NumberInputOwnerState;
+ }
+>;
+
+export type NumberInputDecrementButtonSlotProps = Simplify<
+ UseNumberInputDecrementButtonSlotProps & {
+ ownerState: NumberInputOwnerState;
+ }
+>;
diff --git a/packages/mui-base/src/Unstable_NumberInput/index.ts b/packages/mui-base/src/Unstable_NumberInput/index.ts
new file mode 100644
index 00000000000000..b83be49446103e
--- /dev/null
+++ b/packages/mui-base/src/Unstable_NumberInput/index.ts
@@ -0,0 +1,7 @@
+'use client';
+export { default } from './NumberInput';
+
+export { default as numberInputClasses } from './numberInputClasses';
+export * from './numberInputClasses';
+
+export * from './NumberInput.types';
diff --git a/packages/mui-base/src/Unstable_NumberInput/numberInputClasses.ts b/packages/mui-base/src/Unstable_NumberInput/numberInputClasses.ts
new file mode 100644
index 00000000000000..e7373f4cc8efa0
--- /dev/null
+++ b/packages/mui-base/src/Unstable_NumberInput/numberInputClasses.ts
@@ -0,0 +1,49 @@
+import generateUtilityClass from '../generateUtilityClass';
+import generateUtilityClasses from '../generateUtilityClasses';
+
+export interface NumberInputClasses {
+ /** Class name applied to the root element. */
+ root: string;
+ /** Class name applied to the root element if the component is a descendant of `FormControl`. */
+ formControl: string;
+ /** Class name applied to the root element if `startAdornment` is provided. */
+ // TODO: adornedStart: string;
+ /** Class name applied to the root element if `endAdornment` is provided. */
+ // TODO: adornedEnd: string;
+ /** Class name applied to the root element if the component is focused. */
+ focused: string;
+ /** Class name applied to the root element if `disabled={true}`. */
+ disabled: string;
+ /** State class applied to the root element if `readOnly={true}`. */
+ readOnly: string;
+ /** State class applied to the root element if `error={true}`. */
+ error: string;
+ /** Class name applied to the input element. */
+ input: string;
+ /** Class name applied to the increment button element. */
+ incrementButton: string;
+ /** Class name applied to the decrement button element. */
+ decrementButton: string;
+}
+
+export type NumberInputClassKey = keyof NumberInputClasses;
+
+export function getNumberInputUtilityClass(slot: string): string {
+ return generateUtilityClass('MuiNumberInput', slot);
+}
+
+const numberInputClasses: NumberInputClasses = generateUtilityClasses('MuiNumberInput', [
+ 'root',
+ 'formControl',
+ 'focused',
+ 'disabled',
+ 'readOnly',
+ 'error',
+ 'input',
+ 'incrementButton',
+ 'decrementButton',
+ // 'adornedStart',
+ // 'adornedEnd',
+]);
+
+export default numberInputClasses;
diff --git a/packages/mui-base/src/index.d.ts b/packages/mui-base/src/index.d.ts
index ea9553b3d37094..8be49ed6cf0f8c 100644
--- a/packages/mui-base/src/index.d.ts
+++ b/packages/mui-base/src/index.d.ts
@@ -38,6 +38,9 @@ export * from './Modal';
export { default as NoSsr } from './NoSsr';
+export { default as Unstable_NumberInput } from './Unstable_NumberInput';
+export * from './Unstable_NumberInput';
+
export { default as OptionGroup } from './OptionGroup';
export * from './OptionGroup';
@@ -104,6 +107,9 @@ export * from './useMenuButton';
export { default as useMenuItem } from './useMenuItem';
export * from './useMenuItem';
+export { default as unstable_useNumberInput } from './unstable_useNumberInput';
+export * from './unstable_useNumberInput';
+
export { default as useOption } from './useOption';
export * from './useOption';
diff --git a/packages/mui-base/src/index.js b/packages/mui-base/src/index.js
index 4535ace2218e14..b7ad15d40d5940 100644
--- a/packages/mui-base/src/index.js
+++ b/packages/mui-base/src/index.js
@@ -35,6 +35,9 @@ export * from './Modal';
export { default as NoSsr } from './NoSsr';
+export { default as Unstable_NumberInput } from './Unstable_NumberInput';
+export * from './Unstable_NumberInput';
+
export { default as OptionGroup } from './OptionGroup';
export * from './OptionGroup';
@@ -97,6 +100,9 @@ export * from './useMenuButton';
export { default as useMenuItem } from './useMenuItem';
export * from './useMenuItem';
+export { default as unstable_useNumberInput } from './unstable_useNumberInput';
+export * from './unstable_useNumberInput';
+
export { default as useOption } from './useOption';
export * from './useOption';
diff --git a/packages/mui-base/src/unstable_useNumberInput/index.ts b/packages/mui-base/src/unstable_useNumberInput/index.ts
new file mode 100644
index 00000000000000..6984f2df6e29b2
--- /dev/null
+++ b/packages/mui-base/src/unstable_useNumberInput/index.ts
@@ -0,0 +1,4 @@
+'use client';
+export { default } from './useNumberInput';
+
+export * from './useNumberInput.types';
diff --git a/packages/mui-base/src/unstable_useNumberInput/useNumberInput.test.tsx b/packages/mui-base/src/unstable_useNumberInput/useNumberInput.test.tsx
new file mode 100644
index 00000000000000..d8b5de3b45351e
--- /dev/null
+++ b/packages/mui-base/src/unstable_useNumberInput/useNumberInput.test.tsx
@@ -0,0 +1,274 @@
+import { expect } from 'chai';
+import { spy } from 'sinon';
+import * as React from 'react';
+import userEvent from '@testing-library/user-event';
+import { createRenderer, screen } from 'test/utils';
+import useNumberInput, { UseNumberInputParameters } from './index';
+
+describe('useNumberInput', () => {
+ const { render } = createRenderer();
+ const invokeUseNumberInput = (props: UseNumberInputParameters) => {
+ const ref = React.createRef>();
+ function TestComponent() {
+ const numberInputDefinition = useNumberInput(props);
+ React.useImperativeHandle(ref, () => numberInputDefinition, [numberInputDefinition]);
+ return null;
+ }
+
+ render( );
+
+ return ref.current!;
+ };
+
+ it('should return correct ARIA attributes', () => {
+ const INPUT_ID = 'TestInput';
+
+ const props: UseNumberInputParameters = {
+ inputId: INPUT_ID,
+ value: 50,
+ min: 10,
+ max: 100,
+ disabled: true,
+ };
+
+ const { getInputProps, getIncrementButtonProps, getDecrementButtonProps } =
+ invokeUseNumberInput(props);
+ const inputProps = getInputProps();
+ const incrementButtonProps = getIncrementButtonProps();
+ const decrementButtonProps = getDecrementButtonProps();
+
+ expect(inputProps['aria-valuenow']).to.equal(50);
+ expect(inputProps['aria-valuemin']).to.equal(10);
+ expect(inputProps['aria-valuemax']).to.equal(100);
+ expect(inputProps['aria-disabled']).to.equal(true);
+
+ expect(decrementButtonProps.tabIndex).to.equal(-1);
+ expect(decrementButtonProps['aria-controls']).to.equal(INPUT_ID);
+ expect(decrementButtonProps['aria-disabled']).to.equal(true);
+
+ expect(incrementButtonProps.tabIndex).to.equal(-1);
+ expect(incrementButtonProps['aria-controls']).to.equal(INPUT_ID);
+ expect(incrementButtonProps['aria-disabled']).to.equal(true);
+ });
+
+ it('should accept defaultValue in uncontrolled mode', () => {
+ const props: UseNumberInputParameters = {
+ defaultValue: 100,
+ disabled: true,
+ required: true,
+ };
+
+ const { getInputProps } = invokeUseNumberInput(props);
+ const inputProps = getInputProps();
+
+ expect(inputProps.value).to.equal(100);
+ expect(inputProps.required).to.equal(true);
+ });
+
+ describe('prop: onInputChange', () => {
+ it('should call onInputChange accordingly when inputting valid characters', async () => {
+ const user = userEvent.setup();
+ const handleInputChange = spy();
+ function NumberInput() {
+ const { getInputProps } = useNumberInput({ onInputChange: handleInputChange });
+
+ return ;
+ }
+ render( );
+
+ const input = screen.getByTestId('test-input') as HTMLInputElement;
+
+ await user.click(input);
+
+ await user.keyboard('-12');
+
+ expect(handleInputChange.callCount).to.equal(3);
+ expect(handleInputChange.args[2][0].target.value).to.equal('-12');
+ expect(input.value).to.equal('-12');
+ });
+
+ it('should not change the input value when inputting invalid characters', async () => {
+ const user = userEvent.setup();
+ const handleInputChange = spy();
+ function NumberInput() {
+ const { getInputProps } = useNumberInput({ onInputChange: handleInputChange });
+
+ return ;
+ }
+ render( );
+
+ const input = screen.getByTestId('test-input') as HTMLInputElement;
+
+ await user.click(input);
+
+ await user.keyboard('-5a');
+
+ expect(handleInputChange.callCount).to.equal(3);
+ expect(input.value).to.equal('-5');
+ });
+ });
+
+ describe('prop: onChange', () => {
+ it('should call onChange when the input is blurred', async () => {
+ const user = userEvent.setup();
+ const handleChange = spy();
+ function NumberInput() {
+ const { getInputProps } = useNumberInput({ onChange: handleChange });
+
+ return ;
+ }
+ render( );
+
+ const input = screen.getByTestId('test-input');
+
+ await user.click(input);
+
+ await user.keyboard('34');
+
+ expect(handleChange.callCount).to.equal(0);
+
+ await user.keyboard('[Tab]');
+ expect(document.activeElement).to.equal(document.body);
+
+ expect(handleChange.callCount).to.equal(1);
+ });
+
+ it('should call onChange with a value within max', async () => {
+ const user = userEvent.setup();
+ const handleChange = spy();
+ function NumberInput() {
+ const { getInputProps } = useNumberInput({
+ onChange: handleChange,
+ max: 5,
+ });
+
+ return ;
+ }
+ render( );
+
+ const input = screen.getByTestId('test-input');
+
+ await user.click(input);
+
+ await user.keyboard('9');
+
+ await user.keyboard('[Tab]');
+ expect(document.activeElement).to.equal(document.body);
+
+ expect(handleChange.args[0][1]).to.equal(5);
+ });
+
+ it('should call onChange with a value within min', async () => {
+ const user = userEvent.setup();
+ const handleChange = spy();
+ function NumberInput() {
+ const { getInputProps } = useNumberInput({
+ onChange: handleChange,
+ min: 5,
+ });
+
+ return ;
+ }
+ render( );
+
+ const input = screen.getByTestId('test-input');
+
+ await user.click(input);
+
+ await user.keyboard('-9');
+
+ await user.keyboard('[Tab]');
+ expect(document.activeElement).to.equal(document.body);
+
+ expect(handleChange.args[0][1]).to.equal(5);
+ });
+
+ it('should call onChange with a value based on a custom step', async () => {
+ const user = userEvent.setup();
+ const handleChange = spy();
+ function NumberInput() {
+ const { getInputProps } = useNumberInput({
+ onChange: handleChange,
+ min: 0,
+ step: 5,
+ });
+
+ return ;
+ }
+ render( );
+
+ const input = screen.getByTestId('test-input');
+
+ await user.click(input);
+
+ await user.keyboard('4');
+
+ await user.keyboard('[Tab]');
+ expect(document.activeElement).to.equal(document.body);
+
+ expect(handleChange.args[0][1]).to.equal(5);
+ });
+
+ it('should call onChange with undefined when the value is cleared', async () => {
+ const user = userEvent.setup();
+ const handleChange = spy();
+ function NumberInput() {
+ const { getInputProps } = useNumberInput({
+ onChange: handleChange,
+ });
+
+ return ;
+ }
+ render( );
+
+ const input = screen.getByTestId('test-input') as HTMLInputElement;
+
+ await user.click(input);
+
+ await user.keyboard('9');
+
+ expect(input.value).to.equal('9');
+
+ await user.keyboard('[Backspace]');
+
+ expect(input.value).to.equal('');
+
+ await user.keyboard('[Tab]');
+ expect(document.activeElement).to.equal(document.body);
+
+ expect(handleChange.callCount).to.equal(1);
+ expect(handleChange.args[0][1]).to.equal(undefined);
+ });
+
+ it('should call onChange with undefined when input value is -', async () => {
+ const user = userEvent.setup();
+ const handleChange = spy();
+ function NumberInput() {
+ const { getInputProps } = useNumberInput({
+ onChange: handleChange,
+ });
+
+ return ;
+ }
+ render( );
+
+ const input = screen.getByTestId('test-input') as HTMLInputElement;
+
+ await user.click(input);
+
+ await user.keyboard('-5');
+
+ expect(input.value).to.equal('-5');
+
+ await user.keyboard('[Backspace]');
+
+ expect(input.value).to.equal('-');
+
+ await user.keyboard('[Tab]');
+ expect(document.activeElement).to.equal(document.body);
+
+ expect(handleChange.callCount).to.equal(1);
+ expect(handleChange.args[0][1]).to.equal(undefined);
+ });
+ });
+});
diff --git a/packages/mui-base/src/unstable_useNumberInput/useNumberInput.ts b/packages/mui-base/src/unstable_useNumberInput/useNumberInput.ts
new file mode 100644
index 00000000000000..754b9db595845c
--- /dev/null
+++ b/packages/mui-base/src/unstable_useNumberInput/useNumberInput.ts
@@ -0,0 +1,377 @@
+'use client';
+import * as React from 'react';
+import MuiError from '@mui/utils/macros/MuiError.macro';
+import { unstable_useForkRef as useForkRef, unstable_useId as useId } from '@mui/utils';
+import { FormControlState, useFormControlContext } from '../FormControl';
+import {
+ UseNumberInputParameters,
+ UseNumberInputRootSlotProps,
+ UseNumberInputInputSlotProps,
+ UseNumberInputIncrementButtonSlotProps,
+ UseNumberInputDecrementButtonSlotProps,
+ UseNumberInputReturnValue,
+} from './useNumberInput.types';
+import { clamp, isNumber } from './utils';
+import extractEventHandlers from '../utils/extractEventHandlers';
+
+type StepDirection = 'up' | 'down';
+
+const STEP_KEYS = ['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown'];
+
+const SUPPORTED_KEYS = [...STEP_KEYS, 'Home', 'End'];
+
+function parseInput(v: string): string {
+ return v ? String(v.trim()) : String(v);
+}
+
+/**
+ *
+ * Demos:
+ *
+ * - [Number Input](https://mui.com/base-ui/react-number-input/#hook)
+ *
+ * API:
+ *
+ * - [useNumberInput API](https://mui.com/base-ui/react-number-input/hooks-api/#use-number-input)
+ */
+export default function useNumberInput(
+ parameters: UseNumberInputParameters,
+): UseNumberInputReturnValue {
+ const {
+ min,
+ max,
+ step,
+ shiftMultiplier = 10,
+ defaultValue: defaultValueProp,
+ disabled: disabledProp = false,
+ error: errorProp = false,
+ onBlur,
+ onInputChange,
+ onFocus,
+ onChange,
+ required: requiredProp = false,
+ readOnly: readOnlyProp = false,
+ value: valueProp,
+ inputRef: inputRefProp,
+ inputId: inputIdProp,
+ } = parameters;
+
+ // TODO: make it work with FormControl
+ const formControlContext: FormControlState | undefined = useFormControlContext();
+
+ const { current: isControlled } = React.useRef(valueProp != null);
+
+ const handleInputRefWarning = React.useCallback((instance: HTMLElement) => {
+ if (process.env.NODE_ENV !== 'production') {
+ if (instance && instance.nodeName !== 'INPUT' && !instance.focus) {
+ console.error(
+ [
+ 'MUI: You have provided a `slots.input` to the input component',
+ 'that does not correctly handle the `ref` prop.',
+ 'Make sure the `ref` prop is called with a HTMLInputElement.',
+ ].join('\n'),
+ );
+ }
+ }
+ }, []);
+
+ const inputRef = React.useRef(null);
+ const handleInputRef = useForkRef(inputRef, inputRefProp, handleInputRefWarning);
+
+ const inputId = useId(inputIdProp);
+
+ const [focused, setFocused] = React.useState(false);
+
+ // the "final" value
+ const [value, setValue] = React.useState(valueProp ?? defaultValueProp);
+ // the (potentially) dirty or invalid input value
+ const [dirtyValue, setDirtyValue] = React.useState(
+ value ? String(value) : undefined,
+ );
+
+ React.useEffect(() => {
+ if (!formControlContext && disabledProp && focused) {
+ setFocused(false);
+
+ onBlur?.();
+ }
+ }, [formControlContext, disabledProp, focused, onBlur]);
+
+ const handleFocus =
+ (otherHandlers: Record | undefined>) =>
+ (event: React.FocusEvent) => {
+ otherHandlers.onFocus?.(event);
+
+ if (event.defaultPrevented) {
+ return;
+ }
+
+ if (formControlContext && formControlContext.onFocus) {
+ formControlContext?.onFocus?.();
+ }
+ setFocused(true);
+ };
+
+ const handleValueChange =
+ () =>
+ (
+ event: React.FocusEvent | React.PointerEvent | React.KeyboardEvent,
+ val: number | undefined,
+ ) => {
+ let newValue;
+
+ if (val === undefined) {
+ newValue = val;
+ setDirtyValue('');
+ } else {
+ newValue = clamp(val, min, max, step);
+ setDirtyValue(String(newValue));
+ }
+
+ setValue(newValue);
+
+ if (isNumber(newValue)) {
+ onChange?.(event, newValue);
+ } else {
+ onChange?.(event, undefined);
+ }
+ };
+
+ const handleInputChange =
+ (otherHandlers: Record | undefined>) =>
+ (event: React.ChangeEvent) => {
+ if (!isControlled && event.target === null) {
+ throw new MuiError(
+ 'MUI: Expected valid input target. ' +
+ 'Did you use a custom `slots.input` and forget to forward refs? ' +
+ 'See https://mui.com/r/input-component-ref-interface for more info.',
+ );
+ }
+
+ formControlContext?.onChange?.(event);
+
+ otherHandlers.onInputChange?.(event);
+
+ const val = parseInput(event.currentTarget.value);
+
+ if (val === '' || val === '-') {
+ setDirtyValue(val);
+ setValue(undefined);
+ }
+
+ if (val.match(/^-?\d+?$/)) {
+ setDirtyValue(val);
+ setValue(parseInt(val, 10));
+ }
+ };
+
+ const handleBlur =
+ (otherHandlers: Record | undefined>) =>
+ (event: React.FocusEvent) => {
+ const val = parseInput(event.currentTarget.value);
+
+ otherHandlers.onBlur?.(event);
+
+ if (val === '' || val === '-') {
+ handleValueChange()(event, undefined);
+ } else {
+ handleValueChange()(event, parseInt(val, 10));
+ }
+
+ if (formControlContext && formControlContext.onBlur) {
+ formControlContext.onBlur();
+ }
+
+ setFocused(false);
+ };
+
+ const handleClick =
+ (otherHandlers: Record>) =>
+ (event: React.MouseEvent) => {
+ if (inputRef.current && event.currentTarget === event.target) {
+ inputRef.current.focus();
+ }
+
+ otherHandlers.onClick?.(event);
+ };
+
+ const handleStep =
+ (direction: StepDirection) => (event: React.PointerEvent | React.KeyboardEvent) => {
+ let newValue;
+
+ if (isNumber(value)) {
+ const multiplier =
+ event.shiftKey ||
+ (event as React.KeyboardEvent).key === 'PageUp' ||
+ (event as React.KeyboardEvent).key === 'PageDown'
+ ? shiftMultiplier
+ : 1;
+ newValue = {
+ up: value + (step ?? 1) * multiplier,
+ down: value - (step ?? 1) * multiplier,
+ }[direction];
+ } else {
+ // no value
+ newValue = {
+ up: min ?? 0,
+ down: max ?? 0,
+ }[direction];
+ }
+ handleValueChange()(event, newValue);
+ };
+
+ const handleKeyDown =
+ (otherHandlers: Record | undefined>) =>
+ (event: React.KeyboardEvent) => {
+ otherHandlers.onKeyDown?.(event);
+
+ if (event.defaultPrevented) {
+ return;
+ }
+
+ if (SUPPORTED_KEYS.includes(event.key)) {
+ event.preventDefault();
+ }
+
+ if (STEP_KEYS.includes(event.key)) {
+ const direction = {
+ ArrowUp: 'up',
+ ArrowDown: 'down',
+ PageUp: 'up',
+ PageDown: 'down',
+ }[event.key] as StepDirection;
+
+ handleStep(direction)(event);
+ }
+
+ if (event.key === 'Home' && isNumber(max)) {
+ handleValueChange()(event, max);
+ }
+
+ if (event.key === 'End' && isNumber(min)) {
+ handleValueChange()(event, min);
+ }
+ };
+
+ const getRootProps = = {}>(
+ externalProps: TOther = {} as TOther,
+ ): UseNumberInputRootSlotProps => {
+ const propsEventHandlers = extractEventHandlers(parameters, [
+ 'onBlur',
+ 'onInputChange',
+ 'onFocus',
+ 'onChange',
+ ]);
+
+ const externalEventHandlers = { ...propsEventHandlers, ...extractEventHandlers(externalProps) };
+
+ return {
+ ...externalProps,
+ ...externalEventHandlers,
+ onClick: handleClick(externalEventHandlers),
+ };
+ };
+
+ const getInputProps = = {}>(
+ externalProps: TOther = {} as TOther,
+ ): UseNumberInputInputSlotProps => {
+ const externalEventHandlers = {
+ onBlur,
+ onFocus,
+ ...extractEventHandlers(externalProps, ['onInputChange']),
+ };
+
+ const mergedEventHandlers = {
+ ...externalProps,
+ ...externalEventHandlers,
+ onFocus: handleFocus(externalEventHandlers),
+ onChange: handleInputChange({ ...externalEventHandlers, onInputChange }),
+ onBlur: handleBlur(externalEventHandlers),
+ onKeyDown: handleKeyDown(externalEventHandlers),
+ };
+
+ const displayValue = (focused ? dirtyValue : value) ?? '';
+
+ return {
+ ...mergedEventHandlers,
+ type: 'text',
+ id: inputId,
+ 'aria-invalid': errorProp || undefined,
+ defaultValue: undefined,
+ ref: handleInputRef,
+ value: displayValue as number | undefined,
+ 'aria-valuenow': displayValue as number | undefined,
+ 'aria-valuetext': String(displayValue),
+ 'aria-valuemin': min,
+ 'aria-valuemax': max,
+ autoComplete: 'off',
+ autoCorrect: 'off',
+ spellCheck: 'false',
+ required: requiredProp,
+ readOnly: readOnlyProp,
+ 'aria-disabled': disabledProp,
+ disabled: disabledProp,
+ };
+ };
+
+ const handleStepperButtonMouseDown = (event: React.PointerEvent) => {
+ event.preventDefault();
+
+ if (inputRef.current) {
+ inputRef.current.focus();
+ }
+ };
+
+ const stepperButtonCommonProps = {
+ 'aria-controls': inputId,
+ tabIndex: -1,
+ };
+
+ const isIncrementDisabled =
+ disabledProp || (isNumber(value) ? value >= (max ?? Number.MAX_SAFE_INTEGER) : false);
+
+ const getIncrementButtonProps = = {}>(
+ externalProps: TOther = {} as TOther,
+ ): UseNumberInputIncrementButtonSlotProps => {
+ return {
+ ...externalProps,
+ ...stepperButtonCommonProps,
+ disabled: isIncrementDisabled,
+ 'aria-disabled': isIncrementDisabled,
+ onMouseDown: handleStepperButtonMouseDown,
+ onClick: handleStep('up'),
+ };
+ };
+
+ const isDecrementDisabled =
+ disabledProp || (isNumber(value) ? value <= (min ?? Number.MIN_SAFE_INTEGER) : false);
+
+ const getDecrementButtonProps = = {}>(
+ externalProps: TOther = {} as TOther,
+ ): UseNumberInputDecrementButtonSlotProps => {
+ return {
+ ...externalProps,
+ ...stepperButtonCommonProps,
+ disabled: isDecrementDisabled,
+ 'aria-disabled': isDecrementDisabled,
+ onMouseDown: handleStepperButtonMouseDown,
+ onClick: handleStep('down'),
+ };
+ };
+
+ return {
+ disabled: disabledProp,
+ error: errorProp,
+ focused,
+ formControlContext,
+ getInputProps,
+ getIncrementButtonProps,
+ getDecrementButtonProps,
+ getRootProps,
+ required: requiredProp,
+ value: focused ? dirtyValue : value,
+ isIncrementDisabled,
+ isDecrementDisabled,
+ inputValue: dirtyValue,
+ };
+}
diff --git a/packages/mui-base/src/unstable_useNumberInput/useNumberInput.types.ts b/packages/mui-base/src/unstable_useNumberInput/useNumberInput.types.ts
new file mode 100644
index 00000000000000..6415fc75732421
--- /dev/null
+++ b/packages/mui-base/src/unstable_useNumberInput/useNumberInput.types.ts
@@ -0,0 +1,227 @@
+import * as React from 'react';
+import { FormControlState } from '../FormControl';
+
+export type UseNumberInputChangeHandler = (
+ e: React.KeyboardEvent,
+ value: number | null,
+) => void;
+
+export interface UseNumberInputParameters {
+ /**
+ * The minimum value.
+ */
+ min?: number;
+ /**
+ * The maximum value.
+ */
+ max?: number;
+ /**
+ * The amount that the value changes on each increment or decrement.
+ */
+ step?: number;
+ /**
+ * Multiplier applied to `step` if the shift key is held while incrementing
+ * or decrementing the value. Defaults to `10`.
+ */
+ shiftMultiplier?: number;
+ /**
+ * The default value. Use when the component is not controlled.
+ */
+ defaultValue?: unknown;
+ /**
+ * If `true`, the component is disabled.
+ * The prop defaults to the value (`false`) inherited from the parent FormControl component.
+ */
+ disabled?: boolean;
+ /**
+ * If `true`, the `input` will indicate an error by setting the `aria-invalid` attribute.
+ * The prop defaults to the value (`false`) inherited from the parent FormControl component.
+ */
+ error?: boolean;
+ onBlur?: (event?: React.FocusEvent) => void;
+ onClick?: React.MouseEventHandler;
+ /**
+ * Callback fired when the `input` value changes after each keypress, before clamping is applied.
+ * Note that `event.target.value` may contain values that fall outside of `min` and `max` or
+ * are otherwise "invalid".
+ *
+ * @param {React.ChangeEvent} event The event source of the callback.
+ */
+ onInputChange?: React.ChangeEventHandler;
+ onFocus?: React.FocusEventHandler;
+ /**
+ * Callback fired after the value is clamped and changes - when the `input` is blurred or when
+ * the stepper buttons are triggered.
+ * Called with `undefined` when the value is unset.
+ *
+ * @param {React.FocusEvent|React.PointerEvent|React.KeyboardEvent} event The event source of the callback
+ * @param {number|undefined} value The new value of the component
+ */
+ onChange?: (
+ event: React.FocusEvent | React.PointerEvent | React.KeyboardEvent,
+ value: number | undefined,
+ ) => void;
+ /**
+ * The `id` attribute of the input element.
+ */
+ inputId?: string;
+ /**
+ * The ref of the input element.
+ */
+ inputRef?: React.Ref;
+ /**
+ * If `true`, the `input` element is required.
+ * The prop defaults to the value (`false`) inherited from the parent FormControl component.
+ */
+ required?: boolean;
+ /**
+ * If `true`, the `input` element becomes read-only. The stepper buttons remain active,
+ * with the addition that they are now keyboard focusable.
+ * @default false
+ */
+ readOnly?: boolean;
+ /**
+ * The current value. Use when the component is controlled.
+ */
+ value?: unknown;
+}
+
+export interface UseNumberInputRootSlotOwnProps {
+ onClick: React.MouseEventHandler | undefined;
+}
+
+export type UseNumberInputRootSlotProps = Omit<
+ TOther,
+ keyof UseNumberInputRootSlotOwnProps | 'onBlur' | 'onInputChange' | 'onFocus'
+> &
+ UseNumberInputRootSlotOwnProps;
+
+export interface UseNumberInputInputSlotOwnProps {
+ defaultValue: number | undefined;
+ id: string | undefined;
+ ref: React.RefCallback | null;
+ value: number | undefined;
+ role?: React.AriaRole;
+ 'aria-disabled': React.AriaAttributes['aria-disabled'];
+ 'aria-valuemax': React.AriaAttributes['aria-valuemax'];
+ 'aria-valuemin': React.AriaAttributes['aria-valuemin'];
+ 'aria-valuenow': React.AriaAttributes['aria-valuenow'];
+ 'aria-valuetext': React.AriaAttributes['aria-valuetext'];
+ tabIndex?: number;
+ onBlur: React.FocusEventHandler;
+ onChange: React.ChangeEventHandler;
+ onFocus: React.FocusEventHandler;
+ required: boolean;
+ disabled: boolean;
+}
+
+export type UseNumberInputInputSlotProps = Omit<
+ TOther,
+ keyof UseNumberInputInputSlotOwnProps
+> &
+ UseNumberInputInputSlotOwnProps;
+
+export interface UseNumberInputIncrementButtonSlotOwnProps {
+ 'aria-controls': React.AriaAttributes['aria-controls'];
+ 'aria-disabled': React.AriaAttributes['aria-disabled'];
+ disabled: boolean;
+ tabIndex?: number;
+}
+
+export type UseNumberInputIncrementButtonSlotProps = Omit<
+ TOther,
+ keyof UseNumberInputIncrementButtonSlotOwnProps
+> &
+ UseNumberInputIncrementButtonSlotOwnProps;
+
+export interface UseNumberInputDecrementButtonSlotOwnProps {
+ 'aria-controls': React.AriaAttributes['aria-controls'];
+ 'aria-disabled': React.AriaAttributes['aria-disabled'];
+ disabled: boolean;
+ tabIndex?: number;
+}
+
+export type UseNumberInputDecrementButtonSlotProps = Omit<
+ TOther,
+ keyof UseNumberInputDecrementButtonSlotOwnProps
+> &
+ UseNumberInputDecrementButtonSlotOwnProps;
+
+export interface UseNumberInputReturnValue {
+ /**
+ * If `true`, the component will be disabled.
+ * @default false
+ */
+ disabled: boolean;
+ /**
+ * If `true`, the `input` will indicate an error by setting the `aria-invalid` attribute.
+ * @default false
+ */
+ error: boolean;
+ /**
+ * If `true`, the `input` will be focused.
+ * @default false
+ */
+ focused: boolean;
+ /**
+ * Return value from the `useFormControlContext` hook.
+ */
+ formControlContext: FormControlState | undefined;
+ /**
+ * Resolver for the decrement button slot's props.
+ * @param externalProps props for the decrement button slot
+ * @returns props that should be spread on the decrement button slot
+ */
+ getDecrementButtonProps: = {}>(
+ externalProps?: TOther,
+ ) => UseNumberInputDecrementButtonSlotProps;
+ /**
+ * Resolver for the increment button slot's props.
+ * @param externalProps props for the increment button slot
+ * @returns props that should be spread on the increment button slot
+ */
+ getIncrementButtonProps: = {}>(
+ externalProps?: TOther,
+ ) => UseNumberInputIncrementButtonSlotProps;
+ /**
+ * Resolver for the input slot's props.
+ * @param externalProps props for the input slot
+ * @returns props that should be spread on the input slot
+ */
+ getInputProps: = {}>(
+ externalProps?: TOther,
+ ) => UseNumberInputInputSlotProps;
+ /**
+ * 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,
+ ) => UseNumberInputRootSlotProps;
+ /**
+ * If `true`, the `input` will indicate that it's required.
+ * @default false
+ */
+ required: boolean;
+ /**
+ * The clamped `value` of the `input` element.
+ */
+ value: unknown;
+ /**
+ * The dirty `value` of the `input` element when it is in focus.
+ */
+ inputValue: string | undefined;
+ /**
+ * If `true`, the increment button will be disabled.
+ * e.g. when the `value` is already at `max`
+ * @default false
+ */
+ isIncrementDisabled: boolean;
+ /**
+ * If `true`, the decrement button will be disabled.
+ * e.g. when the `value` is already at `min`
+ * @default false
+ */
+ isDecrementDisabled: boolean;
+}
diff --git a/packages/mui-base/src/unstable_useNumberInput/utils.test.ts b/packages/mui-base/src/unstable_useNumberInput/utils.test.ts
new file mode 100644
index 00000000000000..213294e133bf95
--- /dev/null
+++ b/packages/mui-base/src/unstable_useNumberInput/utils.test.ts
@@ -0,0 +1,50 @@
+import { expect } from 'chai';
+import { clamp, isNumber } from './utils';
+
+describe('utils', () => {
+ it('clamp: clamps a value based on min and max', () => {
+ expect(clamp(1, 2, 4)).to.equal(2);
+ expect(clamp(5, 2, 4)).to.equal(4);
+ expect(clamp(-5, -1, 5)).to.equal(-1);
+ });
+
+ it('clamp: clamps a value between min and max and on a valid step', () => {
+ expect(clamp(2, -15, 15, 3)).to.equal(3);
+ expect(clamp(-1, -15, 15, 3)).to.equal(0);
+ expect(clamp(5, -15, 15, 3)).to.equal(6);
+ expect(clamp(-5, -15, 15, 3)).to.equal(-6);
+ expect(clamp(-55, -15, 15, 3)).to.equal(-15);
+ expect(clamp(57, -15, 15, 3)).to.equal(15);
+ expect(clamp(3, -20, 20, 5)).to.equal(5);
+ expect(clamp(2, -20, 20, 5)).to.equal(0);
+ expect(clamp(8, -20, 20, 5)).to.equal(10);
+ expect(clamp(-7, -20, 20, 5)).to.equal(-5);
+ });
+
+ it('isNumber: rejects NaN', () => {
+ expect(isNumber(NaN)).to.equal(false);
+ });
+
+ it('isNumber: rejects Infinity', () => {
+ expect(isNumber(Infinity)).to.equal(false);
+ expect(isNumber(-Infinity)).to.equal(false);
+ });
+
+ it('isNumber: rejects falsy values', () => {
+ expect(isNumber('')).to.equal(false);
+ expect(isNumber(undefined)).to.equal(false);
+ expect(isNumber(null)).to.equal(false);
+ });
+
+ it('isNumber: accepts positive and negative integers', () => {
+ expect(isNumber(10)).to.equal(true);
+ expect(isNumber(7)).to.equal(true);
+ expect(isNumber(-20)).to.equal(true);
+ expect(isNumber(-333)).to.equal(true);
+ });
+
+ it('isNumber: accepts 0', () => {
+ expect(isNumber(0)).to.equal(true);
+ expect(isNumber(-0)).to.equal(true);
+ });
+});
diff --git a/packages/mui-base/src/unstable_useNumberInput/utils.ts b/packages/mui-base/src/unstable_useNumberInput/utils.ts
new file mode 100644
index 00000000000000..c20907be5b683a
--- /dev/null
+++ b/packages/mui-base/src/unstable_useNumberInput/utils.ts
@@ -0,0 +1,34 @@
+function simpleClamp(
+ val: number,
+ min: number = Number.MIN_SAFE_INTEGER,
+ max: number = Number.MAX_SAFE_INTEGER,
+): number {
+ return Math.max(min, Math.min(val, max));
+}
+
+export function clamp(
+ val: number,
+ min: number = Number.MIN_SAFE_INTEGER,
+ max: number = Number.MAX_SAFE_INTEGER,
+ stepProp: number = NaN,
+): number {
+ if (Number.isNaN(stepProp)) {
+ return simpleClamp(val, min, max);
+ }
+
+ const step = stepProp || 1;
+
+ const remainder = val % step;
+
+ const positivity = Math.sign(remainder);
+
+ if (Math.abs(remainder) > step / 2) {
+ return simpleClamp(val + positivity * (step - Math.abs(remainder)), min, max);
+ }
+
+ return simpleClamp(val - positivity * Math.abs(remainder), min, max);
+}
+
+export function isNumber(val: unknown): val is number {
+ return typeof val === 'number' && !Number.isNaN(val) && Number.isFinite(val);
+}
diff --git a/packages/mui-utils/src/composeClasses/composeClasses.ts b/packages/mui-utils/src/composeClasses/composeClasses.ts
index cf049e73c5d15f..259ed7a05534c3 100644
--- a/packages/mui-utils/src/composeClasses/composeClasses.ts
+++ b/packages/mui-utils/src/composeClasses/composeClasses.ts
@@ -6,7 +6,7 @@ export default function composeClasses(
const output: Record = {} as any;
Object.keys(slots).forEach(
- // `Objet.keys(slots)` can't be wider than `T` because we infer `T` from `slots`.
+ // `Object.keys(slots)` can't be wider than `T` because we infer `T` from `slots`.
// @ts-expect-error https://github.com/microsoft/TypeScript/pull/12253#issuecomment-263132208
(slot: ClassKey) => {
output[slot] = slots[slot]
diff --git a/yarn.lock b/yarn.lock
index c8a83c3ce29895..49a89b40a7ae2d 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2999,6 +2999,11 @@
"@testing-library/dom" "^9.0.0"
"@types/react-dom" "^18.0.0"
+"@testing-library/user-event@^14.4.3":
+ version "14.4.3"
+ resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-14.4.3.tgz#af975e367743fa91989cd666666aec31a8f50591"
+ integrity sha512-kCUc5MEwaEMakkO5x7aoD+DLi02ehmEM2QCGWvNqAS1dV/fAvORWEjnjsEIvml59M7Y5kCkWN6fCCyPOe8OL6Q==
+
"@theme-ui/color-modes@^0.16.0":
version "0.16.0"
resolved "https://registry.yarnpkg.com/@theme-ui/color-modes/-/color-modes-0.16.0.tgz#7e137b7b17be56a4620e90d9a68d6c32cc97e92e"