& {
+ /**
+ * The id of the `input` element.
+ */
+ id?: string;
+ /**
+ * The props used for each slot inside the Input.
+ * @default {}
+ */
+ slotProps?: {
+ input?: SlotComponentProps<
+ 'input',
+ NumberInputUnstyledComponentsPropsOverrides,
+ NumberInputUnstyledOwnerState
+ >;
+ };
+ /**
+ * 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;
+ };
+ };
+
+export interface NumberInputUnstyledTypeMap {
+ props: P & NumberInputUnstyledOwnProps;
+ defaultComponent: D;
+}
+
+export type NumberInputUnstyledProps<
+ D extends React.ElementType = NumberInputUnstyledTypeMap['defaultComponent'],
+ P = {},
+> = OverrideProps, D> & {
+ component?: D;
+};
+
+export type NumberInputUnstyledOwnerState = Simplify<
+ Omit & {
+ // formControlContext: FormControlUnstyledState | undefined;
+ // focused: boolean;
+ type: React.InputHTMLAttributes['type'] | undefined;
+ }
+>;
diff --git a/packages/mui-base/src/NumberInputUnstyled/clamp.test.ts b/packages/mui-base/src/NumberInputUnstyled/clamp.test.ts
new file mode 100644
index 00000000000000..7c9781b863eb01
--- /dev/null
+++ b/packages/mui-base/src/NumberInputUnstyled/clamp.test.ts
@@ -0,0 +1,23 @@
+import { expect } from 'chai';
+import clamp from './clamp';
+
+describe('clamp', () => {
+ it('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('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);
+ });
+});
diff --git a/packages/mui-base/src/NumberInputUnstyled/clamp.ts b/packages/mui-base/src/NumberInputUnstyled/clamp.ts
new file mode 100644
index 00000000000000..c96c80eddcad2c
--- /dev/null
+++ b/packages/mui-base/src/NumberInputUnstyled/clamp.ts
@@ -0,0 +1,30 @@
+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 default 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);
+}
diff --git a/packages/mui-base/src/NumberInputUnstyled/index.ts b/packages/mui-base/src/NumberInputUnstyled/index.ts
new file mode 100644
index 00000000000000..0fc14b5ffc9d2e
--- /dev/null
+++ b/packages/mui-base/src/NumberInputUnstyled/index.ts
@@ -0,0 +1,7 @@
+export { default } from './NumberInputUnstyled';
+
+export * from './NumberInputUnstyled.types';
+
+export { default as useNumberInput } from './useNumberInput';
+
+export * from './useNumberInput.types';
diff --git a/packages/mui-base/src/NumberInputUnstyled/useNumberInput.test.tsx b/packages/mui-base/src/NumberInputUnstyled/useNumberInput.test.tsx
new file mode 100644
index 00000000000000..8740cf9d7c363e
--- /dev/null
+++ b/packages/mui-base/src/NumberInputUnstyled/useNumberInput.test.tsx
@@ -0,0 +1,59 @@
+import { expect } from 'chai';
+import { spy } from 'sinon';
+import * as React from 'react';
+import { createRenderer, screen, act, fireEvent } 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!;
+ };
+
+ describe('getInputProps', () => {
+ it('should include the incoming uncontrolled props in the output', () => {
+ const props: UseNumberInputParameters = {
+ defaultValue: 100,
+ disabled: true,
+ required: true,
+ };
+
+ const { getInputProps } = invokeUseNumberInput(props);
+ const inputProps = getInputProps();
+
+ expect(inputProps.defaultValue).to.equal(100);
+ expect(inputProps.required).to.equal(true);
+ });
+
+ it('should call onChange if a change event is fired', () => {
+ const handleChange = spy();
+ function NumberInput() {
+ const { getInputProps } = useNumberInput({ onChange: handleChange });
+
+ // TODO: how to make accept my custom onChange ?!
+ // @ts-ignore
+ return ;
+ }
+ render();
+
+ const input = screen.getByRole('spinbutton');
+
+ act(() => {
+ input.focus();
+ fireEvent.change(document.activeElement!, { target: { value: 2 } });
+ input.blur();
+ });
+
+ expect(handleChange.callCount).to.equal(1);
+ });
+ });
+});
diff --git a/packages/mui-base/src/NumberInputUnstyled/useNumberInput.ts b/packages/mui-base/src/NumberInputUnstyled/useNumberInput.ts
new file mode 100644
index 00000000000000..98d44eafa476b2
--- /dev/null
+++ b/packages/mui-base/src/NumberInputUnstyled/useNumberInput.ts
@@ -0,0 +1,229 @@
+import * as React from 'react';
+import MuiError from '@mui/utils/macros/MuiError.macro';
+import {
+ // unstable_useControlled as useControlled, // TODO: do I need this?
+ unstable_useForkRef as useForkRef,
+} from '@mui/utils';
+import { FormControlUnstyledState, useFormControlUnstyledContext } from '../FormControlUnstyled';
+import {
+ UseNumberInputParameters,
+ UseNumberInputInputSlotProps,
+ UseNumberInputChangeHandler,
+} from './useNumberInput.types';
+import clamp from './clamp';
+import extractEventHandlers from '../utils/extractEventHandlers';
+
+type EventHandlers = {
+ onBlur?: React.FocusEventHandler;
+ onChange?: UseNumberInputChangeHandler;
+ onFocus?: React.FocusEventHandler;
+};
+
+// TODO
+// 1 - make a proper parser
+// 2 - accept a parser (func) prop
+const parseInput = (v: string): string => {
+ return v ? String(v.trim()) : String(v);
+};
+/**
+ *
+ * API:
+ *
+ * - [useNumberInput API](https://mui.com/base/api/use-number-input/)
+ */
+export default function useNumberInput(parameters: UseNumberInputParameters) {
+ const {
+ // number
+ min,
+ max,
+ step,
+ //
+ defaultValue: defaultValueProp,
+ disabled: disabledProp = false,
+ error: errorProp = false,
+ onFocus,
+ onChange,
+ onBlur,
+ required: requiredProp = false,
+ value: valueProp,
+ inputRef: inputRefProp,
+ } = parameters;
+
+ // TODO: make it work with FormControl
+ const formControlContext: FormControlUnstyledState | undefined = useFormControlUnstyledContext();
+
+ 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 [focused, setFocused] = React.useState(false);
+
+ // the "final" value
+ const [value, setValue] = React.useState(valueProp ?? defaultValueProp);
+ // the (potentially) dirty or invalid input value
+ const [inputValue, setInputValue] = React.useState(undefined);
+
+ React.useEffect(() => {
+ if (!formControlContext && disabledProp && focused) {
+ setFocused(false);
+
+ // @ts-ignore
+ onBlur?.();
+ }
+ }, [formControlContext, disabledProp, focused, onBlur]);
+
+ const handleFocus =
+ (otherHandlers: EventHandlers) => (event: React.FocusEvent) => {
+ // Fix a bug with IE11 where the focus/blur events are triggered
+ // while the component is disabled.
+ if (formControlContext && formControlContext?.disabled) {
+ event.stopPropagation();
+ return;
+ }
+
+ otherHandlers.onFocus?.(event);
+
+ if (formControlContext && formControlContext.onFocus) {
+ formControlContext?.onFocus?.();
+ }
+ setFocused(true);
+ };
+
+ const handleChange =
+ (otherHandlers: EventHandlers) =>
+ (event: React.FocusEvent, val: number | undefined) => {
+ // 1. clamp the number
+ // 2. setInputValue(clamped_value)
+ // 3. call onChange(event, returnValue)
+
+ // console.log('handleChange', val);
+
+ let newValue;
+
+ if (val === undefined) {
+ newValue = val;
+ setInputValue('');
+ } else {
+ newValue = clamp(val, min, max, step);
+ setInputValue(String(newValue));
+ }
+
+ setValue(newValue);
+
+ formControlContext?.onChange?.(event /* newValue */);
+ // TODO: pass an (optional) "newValue" to formControlContext.onChange, this will make FormControlUnstyled work with SelectUnstyled too
+
+ // @ts-ignore
+ otherHandlers.onChange?.(event, newValue);
+ };
+
+ const handleInputChange = () => (event: React.KeyboardEvent) => {
+ if (!isControlled) {
+ const element = event.target || inputRef.current;
+ if (element == 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.',
+ );
+ }
+ }
+
+ const val = parseInput(event.currentTarget.value);
+
+ if (val === '' || val === '-') {
+ setInputValue(val);
+ setValue(undefined);
+ }
+
+ if (val.match(/^-?\d+?$/)) {
+ setInputValue(val);
+ setValue(parseInt(val, 10));
+ }
+ };
+
+ const handleBlur =
+ (otherHandlers: EventHandlers) => (event: React.FocusEvent) => {
+ const val = parseInput(event.currentTarget.value);
+
+ if (val === '' || val === '-') {
+ handleChange(otherHandlers)(event, undefined);
+ } else {
+ handleChange(otherHandlers)(event, parseInt(val, 10));
+ }
+
+ otherHandlers.onBlur?.(event);
+
+ if (formControlContext && formControlContext.onBlur) {
+ formControlContext.onBlur();
+ }
+
+ setFocused(false);
+ };
+
+ const getInputProps = = {}>(
+ externalProps: TOther = {} as TOther,
+ ): UseNumberInputInputSlotProps => {
+ const propsEventHandlers: EventHandlers = {
+ onBlur,
+ onChange,
+ onFocus,
+ };
+
+ const externalEventHandlers = { ...propsEventHandlers, ...extractEventHandlers(externalProps) };
+
+ const mergedEventHandlers = {
+ ...externalProps,
+ ...externalEventHandlers,
+ onFocus: handleFocus(externalEventHandlers),
+ // TODO: will I ever need the other handlers?
+ onChange: handleInputChange(/* externalEventHandlers */),
+ onBlur: handleBlur(externalEventHandlers),
+ };
+
+ return {
+ ...mergedEventHandlers,
+ // TODO: check to see if SR support is still weird
+ role: 'spinbutton',
+ defaultValue: defaultValueProp as string | number | readonly string[] | undefined,
+ ref: handleInputRef,
+ value: ((focused ? inputValue : value) ?? '') as
+ | string
+ | number
+ | readonly string[]
+ | undefined,
+ required: requiredProp,
+ disabled: disabledProp,
+ };
+ };
+
+ return {
+ disabled: disabledProp,
+ error: errorProp,
+ focused,
+ formControlContext,
+ getInputProps,
+ // getIncrementButtonProps,
+ // getDecrementButtonProps,
+ // getRootProps,
+ required: requiredProp,
+ value: focused ? inputValue : value,
+ // private and could be thrown out later
+ inputValue,
+ };
+}
diff --git a/packages/mui-base/src/NumberInputUnstyled/useNumberInput.types.ts b/packages/mui-base/src/NumberInputUnstyled/useNumberInput.types.ts
new file mode 100644
index 00000000000000..039d5ce60fa670
--- /dev/null
+++ b/packages/mui-base/src/NumberInputUnstyled/useNumberInput.types.ts
@@ -0,0 +1,55 @@
+import * as React from 'react';
+
+export type UseNumberInputChangeHandler = (
+ e: React.KeyboardEvent,
+ value: number | null,
+) => void;
+
+export interface UseNumberInputParameters {
+ // props for number specific features
+ min?: number;
+ max?: number;
+ step?: 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?: React.FocusEventHandler;
+ onClick?: React.MouseEventHandler;
+ onChange?: UseNumberInputChangeHandler;
+ onFocus?: React.FocusEventHandler;
+ 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;
+ value?: unknown;
+}
+
+export interface UseNumberInputInputSlotOwnProps {
+ defaultValue: string | number | readonly string[] | undefined;
+ ref: React.Ref;
+ value: string | number | readonly string[] | undefined;
+ onBlur: React.FocusEventHandler;
+ onChange: UseNumberInputChangeHandler;
+ onFocus: React.FocusEventHandler;
+ required: boolean;
+ disabled: boolean;
+}
+
+export type UseNumberInputInputSlotProps = Omit<
+ TOther,
+ keyof UseNumberInputInputSlotOwnProps
+> &
+ UseNumberInputInputSlotOwnProps;
diff --git a/packages/mui-base/src/index.d.ts b/packages/mui-base/src/index.d.ts
index f36f6fd15b9760..b0ce4e5507f5c0 100644
--- a/packages/mui-base/src/index.d.ts
+++ b/packages/mui-base/src/index.d.ts
@@ -35,6 +35,9 @@ export * from './MultiSelectUnstyled';
export { default as NoSsr } from './NoSsr';
+export { default as NumberInputUnstyled } from './NumberInputUnstyled';
+export * from './NumberInputUnstyled';
+
export { default as OptionGroupUnstyled } from './OptionGroupUnstyled';
export * from './OptionGroupUnstyled';
diff --git a/packages/mui-base/src/index.js b/packages/mui-base/src/index.js
index 139e489e6a6a41..436ca486a9a65b 100644
--- a/packages/mui-base/src/index.js
+++ b/packages/mui-base/src/index.js
@@ -32,6 +32,9 @@ export * from './MultiSelectUnstyled';
export { default as NoSsr } from './NoSsr';
+export { default as NumberInputUnstyled } from './NumberInputUnstyled';
+export * from './NumberInputUnstyled';
+
export { default as OptionGroupUnstyled } from './OptionGroupUnstyled';
export * from './OptionGroupUnstyled';