Skip to content

Commit

Permalink
[docs] Create Pickers masked field recipe (#13515)
Browse files Browse the repository at this point in the history
Signed-off-by: Flavien DELANGLE <flaviendelangle@gmail.com>
Co-authored-by: Michel Engelen <32863416+michelengelen@users.noreply.github.com>
  • Loading branch information
flaviendelangle and michelengelen authored Oct 28, 2024
1 parent bc0674a commit 58682ef
Show file tree
Hide file tree
Showing 6 changed files with 352 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -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 (
<TextField
placeholder={parsedFormat}
error={!!hasValidationError}
{...rifmProps}
{...forwardedProps}
/>
);
}

function MaskedFieldDatePicker(props) {
return <DatePicker slots={{ ...props.slots, field: MaskedField }} {...props} />;
}

export default function MaskedMaterialTextField() {
return (
<LocalizationProvider dateAdapter={AdapterDayjs}>
<MaskedFieldDatePicker />
</LocalizationProvider>
);
}
Original file line number Diff line number Diff line change
@@ -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<Dayjs>) {
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<string>(() =>
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 (
<TextField
placeholder={parsedFormat}
error={!!hasValidationError}
{...rifmProps}
{...forwardedProps}
/>
);
}

function MaskedFieldDatePicker(props: DatePickerProps<Dayjs>) {
return <DatePicker slots={{ ...props.slots, field: MaskedField }} {...props} />;
}

export default function MaskedMaterialTextField() {
return (
<LocalizationProvider dateAdapter={AdapterDayjs}>
<MaskedFieldDatePicker />
</LocalizationProvider>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<MaskedFieldDatePicker />
6 changes: 6 additions & 0 deletions docs/data/date-pickers/custom-field/custom-field.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
12 changes: 12 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 58682ef

Please # to comment.