Created
May 22, 2026 03:29
-
-
Save Bloody-Badboy/cddbe2c780bab8e8bac8531d4ec30a32 to your computer and use it in GitHub Desktop.
Reusable MUI TextField wrappers for formatted numeric input and masked text input with validation, control, and seamless third-party integration.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { IMaskInput, IMaskInputProps } from 'react-imask'; | |
| import * as React from 'react'; | |
| import { TextField, TextFieldProps } from '@mui/material'; | |
| type MaskedInputProps = IMaskInputProps<HTMLInputElement>; | |
| const MaskInputCustom = React.forwardRef<HTMLInputElement, MaskedInputProps>( | |
| function MaskInputCustom(props, ref) { | |
| return <IMaskInput {...props} inputRef={ref} />; | |
| }, | |
| ); | |
| type MaskedTextFieldProps = Omit<TextFieldProps, 'onChange'> & { | |
| value?: string; | |
| onChange?: (value: string) => void; | |
| maskInputProps?: MaskedInputProps; | |
| }; | |
| const MaskedTextField = React.forwardRef<HTMLDivElement, MaskedTextFieldProps>( | |
| function MaskedTextField(props, ref) { | |
| const { | |
| value: valueProp, | |
| onChange, | |
| maskInputProps: maskInputPropsProp, | |
| slotProps, | |
| ...rest | |
| } = props; | |
| const isControlled = valueProp !== undefined && onChange !== undefined; | |
| const [fallbackValue, setFallbackValue] = React.useState(valueProp); | |
| const value = isControlled ? valueProp : fallbackValue; | |
| const setValue = isControlled ? onChange : setFallbackValue; | |
| const maskInputProps: MaskedInputProps = { | |
| onAccept: (value) => { | |
| setValue(value); | |
| }, | |
| value, | |
| ...maskInputPropsProp, | |
| }; | |
| return ( | |
| <TextField | |
| {...rest} | |
| ref={ref} | |
| value={value ?? ''} | |
| slotProps={{ | |
| ...slotProps, | |
| input: { | |
| inputComponent: MaskInputCustom, | |
| ...slotProps?.input, | |
| }, | |
| htmlInput: { ...slotProps?.htmlInput, ...maskInputProps }, | |
| }} | |
| /> | |
| ); | |
| }, | |
| ); | |
| export default MaskedTextField; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import * as React from 'react'; | |
| import { NumericFormat, type NumericFormatProps } from 'react-number-format'; | |
| import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; | |
| import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; | |
| import { | |
| IconButton, | |
| type IconButtonProps, | |
| InputAdornment, | |
| Stack, | |
| TextField, | |
| type TextFieldProps, | |
| } from '@mui/material'; | |
| const NumericFormatCustom = React.forwardRef<HTMLInputElement, NumericFormatProps>( | |
| function NumericFormatCustom(props, ref) { | |
| const { decimalSeparator, thousandSeparator, value, ...rest } = props; | |
| return ( | |
| <NumericFormat | |
| {...rest} | |
| value={value ?? ''} // We can't ever pass null to value because it breaks the shrink state of the label, so we pass empty string instead | |
| getInputRef={ref} | |
| thousandSeparator={thousandSeparator ?? ''} | |
| decimalSeparator={decimalSeparator ?? '.'} | |
| /> | |
| ); | |
| }, | |
| ); | |
| type NumberInputProps = Omit<TextFieldProps, 'onChange'> & { | |
| hideActionButtons?: boolean; | |
| max?: number; | |
| min?: number; | |
| numericFormatProps?: NumericFormatProps; | |
| onChange?: (value: number | null) => void; | |
| step?: number; | |
| value?: number | null; | |
| }; | |
| const NumberInput = React.forwardRef<HTMLDivElement, NumberInputProps>( | |
| function NumberInput(props, ref) { | |
| const { | |
| disabled = false, | |
| hideActionButtons = false, | |
| max = Infinity, | |
| min = -Infinity, | |
| numericFormatProps: numericFormatPropsProp, | |
| onChange, | |
| size, | |
| slotProps, | |
| step = 1, | |
| value: valueProp, | |
| ...rest | |
| } = props; | |
| const isControlled = valueProp !== undefined && onChange !== undefined; | |
| // We use an internal state when the component is uncontrolled | |
| const [fallbackValue, setFallbackValue] = React.useState(valueProp); | |
| const value = isControlled ? valueProp : fallbackValue; | |
| const setValue = isControlled ? onChange : setFallbackValue; | |
| const increment = () => { | |
| // If we increment when the input is empty, we consider the previous value to be 0 | |
| const newValue = (value != null && !Number.isNaN(value) ? value : 0) + step; | |
| if (newValue > max) { | |
| return; | |
| } | |
| setValue(newValue); | |
| }; | |
| const decrement = () => { | |
| // If we decrement when the input is empty, we consider the previous value to be 0 | |
| const newValue = (value != null && !Number.isNaN(value) ? value : 0) - step; | |
| if (newValue < min) { | |
| return; | |
| } | |
| setValue(newValue); | |
| }; | |
| const numericFormatProps: NumericFormatProps = { | |
| // We set a default to avoid displaying floating point errors when using a decimal step | |
| decimalScale: 5, | |
| // react-number-format doesn't provide min or max props so we do the checking here instead | |
| isAllowed: ({ floatValue }) => { | |
| if (floatValue == null) { | |
| return true; | |
| } | |
| return floatValue >= min && floatValue <= max; | |
| }, | |
| // Only add the min, max and step html attributes when the value isn't the default one | |
| max: max !== Infinity ? max : undefined, | |
| min: min !== -Infinity ? min : undefined, | |
| step: step !== 1 ? step : undefined, | |
| // Allow to increment with ArrowUp and decrement with ArrowDown | |
| onKeyDown: (event) => { | |
| if (event.key === 'ArrowUp') { | |
| increment(); | |
| } else if (event.key === 'ArrowDown') { | |
| decrement(); | |
| } | |
| }, | |
| onValueChange: ({ floatValue }) => { | |
| // When incrementing or decrementing, the value prop is already up to date | |
| // so we make sure the value needs to be updated to prevent an unnecessary re-render | |
| if (floatValue === value) { | |
| return; | |
| } | |
| setValue(floatValue ?? null); | |
| }, | |
| value, | |
| ...numericFormatPropsProp, | |
| }; | |
| const commonAdornmentButtonProps: IconButtonProps = { | |
| edge: 'end', | |
| sx: { p: size !== 'small' ? '1px' : 0 }, | |
| }; | |
| return ( | |
| <TextField | |
| {...rest} | |
| ref={ref} | |
| value={value ?? ''} // We can't ever pass null to value because it breaks the shrink state of the label, so we pass empty string instead | |
| disabled={disabled} | |
| size={size} | |
| slotProps={{ | |
| ...slotProps, | |
| input: { | |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | |
| inputComponent: NumericFormatCustom as any, | |
| endAdornment: !hideActionButtons && ( | |
| <InputAdornment position="end"> | |
| <Stack> | |
| <IconButton | |
| aria-label="NumberInput" | |
| disabled={disabled || (value ?? 0) + step > max} | |
| onClick={increment} | |
| {...commonAdornmentButtonProps} | |
| > | |
| <KeyboardArrowUpIcon fontSize={size} /> | |
| </IconButton> | |
| <IconButton | |
| aria-label="NumberInput" | |
| disabled={disabled || (value ?? 0) - step < min} | |
| onClick={decrement} | |
| {...commonAdornmentButtonProps} | |
| > | |
| <KeyboardArrowDownIcon fontSize={size} /> | |
| </IconButton> | |
| </Stack> | |
| </InputAdornment> | |
| ), | |
| ...slotProps?.input, | |
| }, | |
| // @ts-expect-error The type should be React.ComponentProps<typeof inputComponent> but instead | |
| // it is hard-coded to InputBaseComponentProps | |
| htmlInput: { ...slotProps?.htmlInput, ...numericFormatProps }, | |
| }} | |
| /> | |
| ); | |
| }, | |
| ); | |
| export default NumberInput; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment