diff --git a/docs/pages/base-ui/api/number-input.json b/docs/pages/base-ui/api/number-input.json index c3d5691a4e999f..77667da4fe300f 100644 --- a/docs/pages/base-ui/api/number-input.json +++ b/docs/pages/base-ui/api/number-input.json @@ -41,7 +41,7 @@ }, "startAdornment": { "type": { "name": "node" } }, "step": { "type": { "name": "number" } }, - "value": { "type": { "name": "any" } } + "value": { "type": { "name": "number" } } }, "name": "NumberInput", "imports": [ diff --git a/docs/pages/base-ui/api/use-number-input.json b/docs/pages/base-ui/api/use-number-input.json index 3d7c9f7512ddfc..70fe3715deea0a 100644 --- a/docs/pages/base-ui/api/use-number-input.json +++ b/docs/pages/base-ui/api/use-number-input.json @@ -40,7 +40,7 @@ "required": { "type": { "name": "boolean", "description": "boolean" } }, "shiftMultiplier": { "type": { "name": "number", "description": "number" } }, "step": { "type": { "name": "number", "description": "number" } }, - "value": { "type": { "name": "unknown", "description": "unknown" } } + "value": { "type": { "name": "number", "description": "number" } } }, "returnValue": { "disabled": { diff --git a/packages/mui-base/src/Unstable_NumberInput/NumberInput.tsx b/packages/mui-base/src/Unstable_NumberInput/NumberInput.tsx index 00d976136dab27..ef9e0839b2bea2 100644 --- a/packages/mui-base/src/Unstable_NumberInput/NumberInput.tsx +++ b/packages/mui-base/src/Unstable_NumberInput/NumberInput.tsx @@ -307,7 +307,7 @@ NumberInput.propTypes /* remove-proptypes */ = { /** * The current value. Use when the component is controlled. */ - value: PropTypes.any, + value: PropTypes.number, } as any; export { NumberInput }; diff --git a/packages/mui-base/src/unstable_useNumberInput/numberInputAction.types.ts b/packages/mui-base/src/unstable_useNumberInput/numberInputAction.types.ts new file mode 100644 index 00000000000000..1373e6d9924c0c --- /dev/null +++ b/packages/mui-base/src/unstable_useNumberInput/numberInputAction.types.ts @@ -0,0 +1,44 @@ +export const NumberInputActionTypes = { + clamp: 'numberInput:clamp', + inputChange: 'numberInput:inputChange', + increment: 'numberInput:increment', + decrement: 'numberInput:decrement', + decrementToMin: 'numberInput:decrementToMin', + incrementToMax: 'numberInput:incrementToMax', +} as const; + +interface NumberInputClampAction { + type: typeof NumberInputActionTypes.clamp; + inputValue: string; +} + +interface NumberInputInputChangeAction { + type: typeof NumberInputActionTypes.inputChange; + inputValue: string; +} + +interface NumberInputIncrementAction { + type: typeof NumberInputActionTypes.increment; + applyMultiplier: boolean; +} + +interface NumberInputDecrementAction { + type: typeof NumberInputActionTypes.decrement; + applyMultiplier: boolean; +} + +interface NumberInputIncrementToMaxAction { + type: typeof NumberInputActionTypes.incrementToMax; +} + +interface NumberInputDecrementToMinAction { + type: typeof NumberInputActionTypes.decrementToMin; +} + +export type NumberInputAction = + | NumberInputClampAction + | NumberInputInputChangeAction + | NumberInputIncrementAction + | NumberInputDecrementAction + | NumberInputIncrementToMaxAction + | NumberInputDecrementToMinAction; diff --git a/packages/mui-base/src/unstable_useNumberInput/numberInputReducer.test.ts b/packages/mui-base/src/unstable_useNumberInput/numberInputReducer.test.ts new file mode 100644 index 00000000000000..461b40901c64f7 --- /dev/null +++ b/packages/mui-base/src/unstable_useNumberInput/numberInputReducer.test.ts @@ -0,0 +1,421 @@ +import { expect } from 'chai'; +import { NumberInputState, NumberInputReducerAction } from './useNumberInput.types'; +import { NumberInputActionTypes } from './numberInputAction.types'; +import { numberInputReducer } from './numberInputReducer'; +import { getInputValueAsString as defaultGetInputValueAsString } from './useNumberInput'; + +describe('numberInputReducer', () => { + describe('action: clamp', () => { + it('clamps the inputValue', () => { + const state: NumberInputState = { + value: 1, + inputValue: '1', + }; + + const action: NumberInputReducerAction = { + type: NumberInputActionTypes.clamp, + inputValue: '1', + context: { + getInputValueAsString: defaultGetInputValueAsString, + shiftMultiplier: 10, + }, + }; + + const result = numberInputReducer(state, action); + + expect(result.value).to.equal(1); + expect(result.inputValue).to.equal('1'); + }); + + it('clamps the inputValue with a custom step', () => { + const state: NumberInputState = { + value: 0, + inputValue: '0', + }; + + const action: NumberInputReducerAction = { + type: NumberInputActionTypes.clamp, + inputValue: '3', + context: { + getInputValueAsString: defaultGetInputValueAsString, + shiftMultiplier: 10, + step: 4, + }, + }; + + const result = numberInputReducer(state, action); + + expect(result.value).to.equal(4); + expect(result.inputValue).to.equal('4'); + }); + + it('clamps the inputValue within min if min is set', () => { + const state: NumberInputState = { + value: 0, + inputValue: '0', + }; + + const action: NumberInputReducerAction = { + type: NumberInputActionTypes.clamp, + inputValue: '0', + context: { + getInputValueAsString: defaultGetInputValueAsString, + shiftMultiplier: 10, + min: 5, + }, + }; + + const result = numberInputReducer(state, action); + + expect(result.value).to.equal(5); + expect(result.inputValue).to.equal('5'); + }); + + it('clamps the inputValue within max if max is set', () => { + const state: NumberInputState = { + value: 10, + inputValue: '10', + }; + + const action: NumberInputReducerAction = { + type: NumberInputActionTypes.clamp, + inputValue: '10', + context: { + getInputValueAsString: defaultGetInputValueAsString, + shiftMultiplier: 10, + max: 9, + }, + }; + + const result = numberInputReducer(state, action); + + expect(result.value).to.equal(9); + expect(result.inputValue).to.equal('9'); + }); + + it('empty value', () => { + const state: NumberInputState = { + value: '', + inputValue: '', + }; + + const action: NumberInputReducerAction = { + type: NumberInputActionTypes.clamp, + inputValue: '', + context: { + getInputValueAsString: defaultGetInputValueAsString, + shiftMultiplier: 10, + }, + }; + + const result = numberInputReducer(state, action); + + expect(result.value).to.equal(''); + expect(result.inputValue).to.equal(''); + }); + }); + + describe('action: inputChange', () => { + it('value contains integers only', () => { + const state: NumberInputState = { + value: 0, + inputValue: '0', + }; + + const action: NumberInputReducerAction = { + type: NumberInputActionTypes.inputChange, + inputValue: '1', + context: { + getInputValueAsString: defaultGetInputValueAsString, + shiftMultiplier: 10, + }, + }; + + const result = numberInputReducer(state, action); + + expect(result.value).to.equal(1); + expect(result.inputValue).to.equal('1'); + }); + + it('value contains invalid characters', () => { + const state: NumberInputState = { + value: 1, + inputValue: '1', + }; + + const action: NumberInputReducerAction = { + type: NumberInputActionTypes.inputChange, + inputValue: '1a', + context: { + getInputValueAsString: defaultGetInputValueAsString, + shiftMultiplier: 10, + }, + }; + + const result = numberInputReducer(state, action); + + expect(result.value).to.equal(1); + expect(result.inputValue).to.equal('1'); + }); + + it('value is minus sign', () => { + const state: NumberInputState = { + value: -1, + inputValue: '-1', + }; + + const action: NumberInputReducerAction = { + type: NumberInputActionTypes.inputChange, + inputValue: '-', + context: { + getInputValueAsString: defaultGetInputValueAsString, + shiftMultiplier: 10, + }, + }; + + const result = numberInputReducer(state, action); + + expect(result.value).to.equal(''); + expect(result.inputValue).to.equal('-'); + }); + + it('empty value', () => { + const state: NumberInputState = { + value: 1, + inputValue: '1', + }; + + const action: NumberInputReducerAction = { + type: NumberInputActionTypes.inputChange, + inputValue: '', + context: { + getInputValueAsString: defaultGetInputValueAsString, + shiftMultiplier: 10, + }, + }; + + const result = numberInputReducer(state, action); + + expect(result.value).to.equal(''); + expect(result.inputValue).to.equal(''); + }); + }); + + describe('action: increment', () => { + it('increments the value', () => { + const state: NumberInputState = { + value: 0, + inputValue: '0', + }; + + const action: NumberInputReducerAction = { + type: NumberInputActionTypes.increment, + applyMultiplier: false, + context: { + getInputValueAsString: defaultGetInputValueAsString, + shiftMultiplier: 10, + }, + }; + + const result = numberInputReducer(state, action); + + expect(result.value).to.equal(1); + expect(result.inputValue).to.equal('1'); + }); + + it('increments the value based on the step prop', () => { + const state: NumberInputState = { + value: 0, + inputValue: '0', + }; + + const action: NumberInputReducerAction = { + type: NumberInputActionTypes.increment, + applyMultiplier: false, + context: { + getInputValueAsString: defaultGetInputValueAsString, + shiftMultiplier: 10, + step: 5, + }, + }; + + const result = numberInputReducer(state, action); + + expect(result.value).to.equal(5); + expect(result.inputValue).to.equal('5'); + }); + + it('applys the shiftMultiplier when incrementing with shift+click', () => { + const state: NumberInputState = { + value: 0, + inputValue: '0', + }; + + const action: NumberInputReducerAction = { + type: NumberInputActionTypes.increment, + applyMultiplier: true, + context: { + getInputValueAsString: defaultGetInputValueAsString, + shiftMultiplier: 10, + step: 1, + }, + }; + + const result = numberInputReducer(state, action); + + expect(result.value).to.equal(10); + expect(result.inputValue).to.equal('10'); + }); + }); + + describe('action: decrement', () => { + it('decrements the value', () => { + const state: NumberInputState = { + value: 15, + inputValue: '15', + }; + + const action: NumberInputReducerAction = { + type: NumberInputActionTypes.decrement, + applyMultiplier: false, + context: { + getInputValueAsString: defaultGetInputValueAsString, + shiftMultiplier: 10, + }, + }; + + const result = numberInputReducer(state, action); + + expect(result.value).to.equal(14); + expect(result.inputValue).to.equal('14'); + }); + + it('decrements the value based on the step prop', () => { + const state: NumberInputState = { + value: 10, + inputValue: '10', + }; + + const action: NumberInputReducerAction = { + type: NumberInputActionTypes.decrement, + applyMultiplier: false, + context: { + getInputValueAsString: defaultGetInputValueAsString, + shiftMultiplier: 10, + step: 2, + }, + }; + + const result = numberInputReducer(state, action); + + expect(result.value).to.equal(8); + expect(result.inputValue).to.equal('8'); + }); + + it('applys the shiftMultiplier when decrementing with shift+click', () => { + const state: NumberInputState = { + value: 20, + inputValue: '20', + }; + + const action: NumberInputReducerAction = { + type: NumberInputActionTypes.decrement, + applyMultiplier: true, + context: { + getInputValueAsString: defaultGetInputValueAsString, + shiftMultiplier: 10, + step: 1, + }, + }; + + const result = numberInputReducer(state, action); + + expect(result.value).to.equal(10); + expect(result.inputValue).to.equal('10'); + }); + }); + + describe('action: incrementToMax', () => { + it('sets the value to max if max is set', () => { + const state: NumberInputState = { + value: 20, + inputValue: '20', + }; + + const action: NumberInputReducerAction = { + type: NumberInputActionTypes.incrementToMax, + context: { + getInputValueAsString: defaultGetInputValueAsString, + max: 99, + shiftMultiplier: 10, + }, + }; + + const result = numberInputReducer(state, action); + + expect(result.value).to.equal(99); + expect(result.inputValue).to.equal('99'); + }); + + it('does not change the state if max is not set', () => { + const state: NumberInputState = { + value: 20, + inputValue: '20', + }; + + const action: NumberInputReducerAction = { + type: NumberInputActionTypes.incrementToMax, + context: { + getInputValueAsString: defaultGetInputValueAsString, + shiftMultiplier: 10, + }, + }; + + const result = numberInputReducer(state, action); + + expect(result).to.equal(state); + }); + }); + + describe('action: decrementToMin', () => { + it('sets the value to min if min is set', () => { + const state: NumberInputState = { + value: 20, + inputValue: '20', + }; + + const action: NumberInputReducerAction = { + type: NumberInputActionTypes.decrementToMin, + context: { + getInputValueAsString: defaultGetInputValueAsString, + min: 1, + shiftMultiplier: 10, + }, + }; + + const result = numberInputReducer(state, action); + + expect(result.value).to.equal(1); + expect(result.inputValue).to.equal('1'); + }); + + it('does not change the state if min is not set', () => { + const state: NumberInputState = { + value: 20, + inputValue: '20', + }; + + const action: NumberInputReducerAction = { + type: NumberInputActionTypes.decrementToMin, + context: { + getInputValueAsString: defaultGetInputValueAsString, + shiftMultiplier: 10, + }, + }; + + const result = numberInputReducer(state, action); + + expect(result).to.equal(state); + }); + }); +}); diff --git a/packages/mui-base/src/unstable_useNumberInput/numberInputReducer.ts b/packages/mui-base/src/unstable_useNumberInput/numberInputReducer.ts new file mode 100644 index 00000000000000..f3fbc18ba9fbfd --- /dev/null +++ b/packages/mui-base/src/unstable_useNumberInput/numberInputReducer.ts @@ -0,0 +1,156 @@ +import { + NumberInputActionContext, + NumberInputReducerAction, + NumberInputState, + StepDirection, +} from './useNumberInput.types'; +import { NumberInputActionTypes } from './numberInputAction.types'; +import { clamp, isNumber } from './utils'; + +// extracted from handleValueChange +function getClampedValues(rawValue: number | undefined, context: NumberInputActionContext) { + const { min, max, step } = context; + + const clampedValue = rawValue === undefined ? '' : clamp(rawValue, min, max, step); + + const newInputValue = clampedValue === undefined ? '' : String(clampedValue); + + return { + value: clampedValue, + inputValue: newInputValue, + }; +} + +function stepValue( + state: NumberInputState, + context: NumberInputActionContext, + direction: StepDirection, + multiplier: number, +) { + const { value } = state; + const { step = 1, min, max } = context; + + if (isNumber(value)) { + return { + up: value + (step ?? 1) * multiplier, + down: value - (step ?? 1) * multiplier, + }[direction]; + } + + return { + up: min ?? 0, + down: max ?? 0, + }[direction]; +} + +function handleClamp( + state: State, + context: NumberInputActionContext, + inputValue: string, +) { + const { getInputValueAsString } = context; + + const numberValueAsString = getInputValueAsString(inputValue); + + const intermediateValue = + numberValueAsString === '' || numberValueAsString === '-' + ? undefined + : parseInt(numberValueAsString, 10); + + const clampedValues = getClampedValues(intermediateValue, context); + + return { + ...state, + ...clampedValues, + }; +} + +function handleInputChange( + state: State, + context: NumberInputActionContext, + inputValue: string, +) { + const { getInputValueAsString } = context; + + const numberValueAsString = getInputValueAsString(inputValue); + + if (numberValueAsString === '' || numberValueAsString === '-') { + return { + ...state, + inputValue: numberValueAsString, + value: '', + }; + } + + if (numberValueAsString.match(/^-?\d+?$/)) { + return { + ...state, + inputValue: numberValueAsString, + value: parseInt(numberValueAsString, 10), + }; + } + + return state; +} + +// use this for ArrowUp, ArrowDown, button clicks +// use this with applyMultiplier: true for PageUp, PageDown, button shift-clicks +function handleStep( + state: State, + context: NumberInputActionContext, + applyMultiplier: boolean, + direction: StepDirection, +) { + const multiplier = applyMultiplier ? context.shiftMultiplier : 1; + + const newValue = stepValue(state, context, direction, multiplier); + + const clampedValues = getClampedValues(newValue, context); + + return { + ...state, + ...clampedValues, + }; +} + +function handleToMinOrMax( + state: State, + context: NumberInputActionContext, + to: 'min' | 'max', +) { + const newValue = context[to]; + + if (!isNumber(newValue)) { + return state; + } + + return { + ...state, + value: newValue, + inputValue: String(newValue), + }; +} + +export function numberInputReducer( + state: NumberInputState, + action: NumberInputReducerAction, +): NumberInputState { + const { type, context } = action; + + switch (type) { + case NumberInputActionTypes.clamp: + return handleClamp(state, context, action.inputValue); + case NumberInputActionTypes.inputChange: + return handleInputChange(state, context, action.inputValue); + case NumberInputActionTypes.increment: + return handleStep(state, context, action.applyMultiplier, 'up'); + case NumberInputActionTypes.decrement: + return handleStep(state, context, action.applyMultiplier, 'down'); + case NumberInputActionTypes.incrementToMax: + return handleToMinOrMax(state, context, 'max'); + case NumberInputActionTypes.decrementToMin: + return handleToMinOrMax(state, context, 'min'); + default: + return state; + } +} diff --git a/packages/mui-base/src/unstable_useNumberInput/useNumberInput.ts b/packages/mui-base/src/unstable_useNumberInput/useNumberInput.ts index c0e547379f8c31..7f97c65537ae3b 100644 --- a/packages/mui-base/src/unstable_useNumberInput/useNumberInput.ts +++ b/packages/mui-base/src/unstable_useNumberInput/useNumberInput.ts @@ -17,16 +17,15 @@ import { UseNumberInputIncrementButtonSlotProps, UseNumberInputDecrementButtonSlotProps, UseNumberInputReturnValue, + StepDirection, } from './useNumberInput.types'; import { clamp, isNumber } from './utils'; -type StepDirection = 'up' | 'down'; - const STEP_KEYS = ['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown']; const SUPPORTED_KEYS = [...STEP_KEYS, 'Home', 'End']; -function parseInput(v: string): string { +export function getInputValueAsString(v: string): string { return v ? String(v.trim()) : String(v); } @@ -165,7 +164,8 @@ export function useNumberInput(parameters: UseNumberInputParameters): UseNumberI return; } - const val = parseInput(event.currentTarget.value); + // TODO: event.currentTarget.value will be passed straight into the InputChange action + const val = getInputValueAsString(event.currentTarget.value); if (val === '' || val === '-') { setDirtyValue(val); @@ -187,7 +187,8 @@ export function useNumberInput(parameters: UseNumberInputParameters): UseNumberI return; } - const val = parseInput(event.currentTarget.value); + // TODO: event.currentTarget.value will be passed straight into the Blur action, or just pass inputValue from state + const val = getInputValueAsString(event.currentTarget.value); if (val === '' || val === '-') { handleValueChange()(event, undefined); diff --git a/packages/mui-base/src/unstable_useNumberInput/useNumberInput.types.ts b/packages/mui-base/src/unstable_useNumberInput/useNumberInput.types.ts index 0bff71d8f37f5c..9108f6be45a913 100644 --- a/packages/mui-base/src/unstable_useNumberInput/useNumberInput.types.ts +++ b/packages/mui-base/src/unstable_useNumberInput/useNumberInput.types.ts @@ -1,10 +1,43 @@ import * as React from 'react'; import { FormControlState } from '../FormControl'; +import { NumberInputAction } from './numberInputAction.types'; +import { ActionWithContext } from '../utils/useControllableReducer.types'; -export type UseNumberInputChangeHandler = ( - e: React.KeyboardEvent, - value: number | null, -) => void; +export type StepDirection = 'up' | 'down'; + +/** + * The internal state of the NumberInput. + * Modify via the reducer only. + */ +export interface NumberInputState { + /** + * The clamped `value` of the `input` element. + */ + value?: number | ''; + /** + * The dirty `value` of the `input` element when it is in focus. + */ + inputValue?: string; +} + +/** + * Additional props passed to the number input reducer actions. + */ +export type NumberInputActionContext = { + min?: number; + max?: number; + step?: number; + shiftMultiplier: number; + /** + * A function that parses the raw input value + */ + getInputValueAsString: (val: string) => string; +}; + +export type NumberInputReducerAction = ActionWithContext< + NumberInputAction, + NumberInputActionContext & CustomActionContext +>; export interface UseNumberInputParameters { /** @@ -83,7 +116,7 @@ export interface UseNumberInputParameters { /** * The current value. Use when the component is controlled. */ - value?: unknown; + value?: number; } export interface UseNumberInputRootSlotOwnProps {