From 58682ef4815871df657055ccc602fc31d318d4fc Mon Sep 17 00:00:00 2001 From: Flavien DELANGLE Date: Mon, 28 Oct 2024 17:04:56 +0100 Subject: [PATCH] [docs] Create Pickers masked field recipe (#13515) Signed-off-by: Flavien DELANGLE Co-authored-by: Michel Engelen <32863416+michelengelen@users.noreply.github.com> --- .../MaskedMaterialTextField.js | 164 +++++++++++++++++ .../MaskedMaterialTextField.tsx | 168 ++++++++++++++++++ .../MaskedMaterialTextField.tsx.preview | 1 + .../date-pickers/custom-field/custom-field.md | 6 + docs/package.json | 1 + pnpm-lock.yaml | 12 ++ 6 files changed, 352 insertions(+) create mode 100644 docs/data/date-pickers/custom-field/custom-behavior/MaskedMaterialTextField.js create mode 100644 docs/data/date-pickers/custom-field/custom-behavior/MaskedMaterialTextField.tsx create mode 100644 docs/data/date-pickers/custom-field/custom-behavior/MaskedMaterialTextField.tsx.preview diff --git a/docs/data/date-pickers/custom-field/custom-behavior/MaskedMaterialTextField.js b/docs/data/date-pickers/custom-field/custom-behavior/MaskedMaterialTextField.js new file mode 100644 index 0000000000000..0723d4565c514 --- /dev/null +++ b/docs/data/date-pickers/custom-field/custom-behavior/MaskedMaterialTextField.js @@ -0,0 +1,164 @@ +import * as React from 'react'; +import dayjs from 'dayjs'; +import { useRifm } from 'rifm'; +import TextField from '@mui/material/TextField'; +import useControlled from '@mui/utils/useControlled'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; +import { DatePicker } from '@mui/x-date-pickers/DatePicker'; +import { useSplitFieldProps, useParsedFormat } from '@mui/x-date-pickers/hooks'; +import { useValidation, validateDate } from '@mui/x-date-pickers/validation'; + +const MASK_USER_INPUT_SYMBOL = '_'; +const ACCEPT_REGEX = /[\d]/gi; + +const staticDateWith2DigitTokens = dayjs('2019-11-21T11:30:00.000'); +const staticDateWith1DigitTokens = dayjs('2019-01-01T09:00:00.000'); + +function getValueStrFromValue(value, format) { + if (value == null) { + return ''; + } + + return value.isValid() ? value.format(format) : ''; +} + +function MaskedField(props) { + const { slots, slotProps, ...other } = props; + + const { forwardedProps, internalProps } = useSplitFieldProps(other, 'date'); + + const { + format, + value: valueProp, + defaultValue, + onChange, + timezone, + onError, + } = internalProps; + + const [value, setValue] = useControlled({ + controlled: valueProp, + default: defaultValue ?? null, + name: 'MaskedField', + state: 'value', + }); + + // Control the input text + const [inputValue, setInputValue] = React.useState(() => + getValueStrFromValue(value, format), + ); + + React.useEffect(() => { + if (value && value.isValid()) { + const newDisplayDate = getValueStrFromValue(value, format); + setInputValue(newDisplayDate); + } + }, [format, value]); + + const parsedFormat = useParsedFormat(internalProps); + + const { hasValidationError, getValidationErrorForNewValue } = useValidation({ + value, + timezone, + onError, + props: internalProps, + validator: validateDate, + }); + + const handleValueStrChange = (newValueStr) => { + setInputValue(newValueStr); + + const newValue = dayjs(newValueStr, format); + setValue(newValue); + + if (onChange) { + onChange(newValue, { + validationError: getValidationErrorForNewValue(newValue), + }); + } + }; + + const rifmFormat = React.useMemo(() => { + const formattedDateWith1Digit = staticDateWith1DigitTokens.format(format); + const inferredFormatPatternWith1Digits = formattedDateWith1Digit.replace( + ACCEPT_REGEX, + MASK_USER_INPUT_SYMBOL, + ); + const inferredFormatPatternWith2Digits = staticDateWith2DigitTokens + .format(format) + .replace(ACCEPT_REGEX, '_'); + + if (inferredFormatPatternWith1Digits !== inferredFormatPatternWith2Digits) { + throw new Error( + `Mask does not support numbers with variable length such as 'M'.`, + ); + } + + const maskToUse = inferredFormatPatternWith1Digits; + + return function formatMaskedDate(valueToFormat) { + let outputCharIndex = 0; + return valueToFormat + .split('') + .map((character, characterIndex) => { + ACCEPT_REGEX.lastIndex = 0; + + if (outputCharIndex > maskToUse.length - 1) { + return ''; + } + + const maskChar = maskToUse[outputCharIndex]; + const nextMaskChar = maskToUse[outputCharIndex + 1]; + + const acceptedChar = ACCEPT_REGEX.test(character) ? character : ''; + const formattedChar = + maskChar === MASK_USER_INPUT_SYMBOL + ? acceptedChar + : maskChar + acceptedChar; + + outputCharIndex += formattedChar.length; + + const isLastCharacter = characterIndex === valueToFormat.length - 1; + if ( + isLastCharacter && + nextMaskChar && + nextMaskChar !== MASK_USER_INPUT_SYMBOL + ) { + // when cursor at the end of mask part (e.g. month) prerender next symbol "21" -> "21/" + return formattedChar ? formattedChar + nextMaskChar : ''; + } + + return formattedChar; + }) + .join(''); + }; + }, [format]); + + const rifmProps = useRifm({ + value: inputValue, + onChange: handleValueStrChange, + format: rifmFormat, + }); + + return ( + + ); +} + +function MaskedFieldDatePicker(props) { + return ; +} + +export default function MaskedMaterialTextField() { + return ( + + + + ); +} diff --git a/docs/data/date-pickers/custom-field/custom-behavior/MaskedMaterialTextField.tsx b/docs/data/date-pickers/custom-field/custom-behavior/MaskedMaterialTextField.tsx new file mode 100644 index 0000000000000..a589022eada63 --- /dev/null +++ b/docs/data/date-pickers/custom-field/custom-behavior/MaskedMaterialTextField.tsx @@ -0,0 +1,168 @@ +import * as React from 'react'; +import dayjs, { Dayjs } from 'dayjs'; +import { useRifm } from 'rifm'; +import TextField from '@mui/material/TextField'; +import useControlled from '@mui/utils/useControlled'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; +import { + DatePicker, + DatePickerProps, + DatePickerFieldProps, +} from '@mui/x-date-pickers/DatePicker'; +import { useSplitFieldProps, useParsedFormat } from '@mui/x-date-pickers/hooks'; +import { useValidation, validateDate } from '@mui/x-date-pickers/validation'; + +const MASK_USER_INPUT_SYMBOL = '_'; +const ACCEPT_REGEX = /[\d]/gi; + +const staticDateWith2DigitTokens = dayjs('2019-11-21T11:30:00.000'); +const staticDateWith1DigitTokens = dayjs('2019-01-01T09:00:00.000'); + +function getValueStrFromValue(value: Dayjs | null, format: string) { + if (value == null) { + return ''; + } + + return value.isValid() ? value.format(format) : ''; +} + +function MaskedField(props: DatePickerFieldProps) { + const { slots, slotProps, ...other } = props; + + const { forwardedProps, internalProps } = useSplitFieldProps(other, 'date'); + + const { + format, + value: valueProp, + defaultValue, + onChange, + timezone, + onError, + } = internalProps; + + const [value, setValue] = useControlled({ + controlled: valueProp, + default: defaultValue ?? null, + name: 'MaskedField', + state: 'value', + }); + + // Control the input text + const [inputValue, setInputValue] = React.useState(() => + getValueStrFromValue(value, format), + ); + + React.useEffect(() => { + if (value && value.isValid()) { + const newDisplayDate = getValueStrFromValue(value, format); + setInputValue(newDisplayDate); + } + }, [format, value]); + + const parsedFormat = useParsedFormat(internalProps); + + const { hasValidationError, getValidationErrorForNewValue } = useValidation({ + value, + timezone, + onError, + props: internalProps, + validator: validateDate, + }); + + const handleValueStrChange = (newValueStr: string) => { + setInputValue(newValueStr); + + const newValue = dayjs(newValueStr, format); + setValue(newValue); + + if (onChange) { + onChange(newValue, { + validationError: getValidationErrorForNewValue(newValue), + }); + } + }; + + const rifmFormat = React.useMemo(() => { + const formattedDateWith1Digit = staticDateWith1DigitTokens.format(format); + const inferredFormatPatternWith1Digits = formattedDateWith1Digit.replace( + ACCEPT_REGEX, + MASK_USER_INPUT_SYMBOL, + ); + const inferredFormatPatternWith2Digits = staticDateWith2DigitTokens + .format(format) + .replace(ACCEPT_REGEX, '_'); + + if (inferredFormatPatternWith1Digits !== inferredFormatPatternWith2Digits) { + throw new Error( + `Mask does not support numbers with variable length such as 'M'.`, + ); + } + + const maskToUse = inferredFormatPatternWith1Digits; + + return function formatMaskedDate(valueToFormat: string) { + let outputCharIndex = 0; + return valueToFormat + .split('') + .map((character, characterIndex) => { + ACCEPT_REGEX.lastIndex = 0; + + if (outputCharIndex > maskToUse.length - 1) { + return ''; + } + + const maskChar = maskToUse[outputCharIndex]; + const nextMaskChar = maskToUse[outputCharIndex + 1]; + + const acceptedChar = ACCEPT_REGEX.test(character) ? character : ''; + const formattedChar = + maskChar === MASK_USER_INPUT_SYMBOL + ? acceptedChar + : maskChar + acceptedChar; + + outputCharIndex += formattedChar.length; + + const isLastCharacter = characterIndex === valueToFormat.length - 1; + if ( + isLastCharacter && + nextMaskChar && + nextMaskChar !== MASK_USER_INPUT_SYMBOL + ) { + // when cursor at the end of mask part (e.g. month) prerender next symbol "21" -> "21/" + return formattedChar ? formattedChar + nextMaskChar : ''; + } + + return formattedChar; + }) + .join(''); + }; + }, [format]); + + const rifmProps = useRifm({ + value: inputValue, + onChange: handleValueStrChange, + format: rifmFormat, + }); + + return ( + + ); +} + +function MaskedFieldDatePicker(props: DatePickerProps) { + return ; +} + +export default function MaskedMaterialTextField() { + return ( + + + + ); +} diff --git a/docs/data/date-pickers/custom-field/custom-behavior/MaskedMaterialTextField.tsx.preview b/docs/data/date-pickers/custom-field/custom-behavior/MaskedMaterialTextField.tsx.preview new file mode 100644 index 0000000000000..1340a82dd55df --- /dev/null +++ b/docs/data/date-pickers/custom-field/custom-behavior/MaskedMaterialTextField.tsx.preview @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/data/date-pickers/custom-field/custom-field.md b/docs/data/date-pickers/custom-field/custom-field.md index 36417d5bde6e4..9c4cc5216bb38 100644 --- a/docs/data/date-pickers/custom-field/custom-field.md +++ b/docs/data/date-pickers/custom-field/custom-field.md @@ -132,6 +132,12 @@ you can replace the field with an `Autocomplete` listing those dates: {{"demo": "PickerWithAutocompleteField.js", "defaultCodeOpen": false}} +### Using a masked Text Field + +If you want to use a simple mask approach for the field editing instead of the built-in logic, you can replace the default field with a Text Field using a masked input value built with the [rifm](https://github.com/realadvisor/rifm) package. + +{{"demo": "custom-behavior/MaskedMaterialTextField.js", "defaultCodeOpen": false}} + ### Using a read-only `TextField` If you want users to select a value exclusively through the views diff --git a/docs/package.json b/docs/package.json index 45bdb6951efb1..7c1d665c25d34 100644 --- a/docs/package.json +++ b/docs/package.json @@ -91,6 +91,7 @@ "react-runner": "^1.0.5", "react-simple-code-editor": "^0.14.1", "recast": "^0.23.9", + "rifm": "0.12.1", "rimraf": "^6.0.1", "rxjs": "^7.8.1", "styled-components": "^6.1.13", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a8b113498a624..20f5d2afcc957 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -620,6 +620,9 @@ importers: recast: specifier: ^0.23.9 version: 0.23.9 + rifm: + specifier: 0.12.1 + version: 0.12.1(react@18.3.1) rimraf: specifier: ^6.0.1 version: 6.0.1 @@ -8910,6 +8913,11 @@ packages: rfdc@1.3.1: resolution: {integrity: sha512-r5a3l5HzYlIC68TpmYKlxWjmOP6wiPJ1vWv2HeLhNsRZMrCkxeqxiHlQ21oXmQ4F3SiryXBHhAD7JZqvOJjFmg==} + rifm@0.12.1: + resolution: {integrity: sha512-OGA1Bitg/dSJtI/c4dh90svzaUPt228kzFsUkJbtA2c964IqEAwWXeL9ZJi86xWv3j5SMqRvGULl7bA6cK0Bvg==} + peerDependencies: + react: '>=16.8' + rimraf@2.6.3: resolution: {integrity: sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==} deprecated: Rimraf versions prior to v4 are no longer supported @@ -18795,6 +18803,10 @@ snapshots: rfdc@1.3.1: {} + rifm@0.12.1(react@18.3.1): + dependencies: + react: 18.3.1 + rimraf@2.6.3: dependencies: glob: 7.2.3