Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[pickers] Infer mask from inputFormat #5060

Merged
merged 11 commits into from
Jun 8, 2022
8 changes: 0 additions & 8 deletions docs/data/date-pickers/date-picker/LocalizedDatePicker.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,6 @@ const localeMap = {
de: deLocale,
};

const maskMap = {
fr: '__/__/____',
en: '__/__/____',
ru: '__.__.____',
de: '__.__.____',
};

export default function LocalizedDatePicker() {
const [locale, setLocale] = React.useState('ru');
const [value, setValue] = React.useState(new Date());
Expand All @@ -50,7 +43,6 @@ export default function LocalizedDatePicker() {
))}
</ToggleButtonGroup>
<DatePicker
mask={maskMap[locale]}
value={value}
onChange={(newValue) => setValue(newValue)}
renderInput={(params) => <TextField {...params} />}
Expand Down
10 changes: 1 addition & 9 deletions docs/data/date-pickers/date-picker/LocalizedDatePicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,8 @@ const localeMap = {
de: deLocale,
};

const maskMap = {
fr: '__/__/____',
en: '__/__/____',
ru: '__.__.____',
de: '__.__.____',
};
Comment on lines -20 to -25
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since default mask is inferred, this can be done


export default function LocalizedDatePicker() {
const [locale, setLocale] = React.useState<keyof typeof maskMap>('ru');
const [locale, setLocale] = React.useState<keyof typeof localeMap>('ru');
const [value, setValue] = React.useState<Date | null>(new Date());

const selectLocale = (newLocale: any) => {
Expand All @@ -50,7 +43,6 @@ export default function LocalizedDatePicker() {
))}
</ToggleButtonGroup>
<DatePicker
mask={maskMap[locale]}
value={value}
onChange={(newValue) => setValue(newValue)}
renderInput={(params) => <TextField {...params} />}
Expand Down
6 changes: 1 addition & 5 deletions packages/x-date-pickers-pro/src/DateRangePicker/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,7 @@ export function useDateRangePickerDefaultizedProps<
name: string,
): DefaultizedProps<Props> &
Required<
Pick<
BaseDateRangePickerProps<TInputDate, TDate>,
'calendars' | 'mask' | 'startText' | 'endText'
>
Pick<BaseDateRangePickerProps<TInputDate, TDate>, 'calendars' | 'startText' | 'endText'>
> {
const utils = useUtils<TDate>();
const defaultDates = useDefaultDates();
Expand All @@ -102,7 +99,6 @@ export function useDateRangePickerDefaultizedProps<

return {
calendars: 2,
mask: '__/__/____',
inputFormat: utils.formats.keyboardDate,
minDate: defaultDates.minDate,
maxDate: defaultDates.maxDate,
Expand Down
2 changes: 2 additions & 0 deletions packages/x-date-pickers-pro/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
{
"extends": "../../tsconfig",
"compilerOptions": {
// @date-io libraries produce duplicate type `DateType`
"skipLibCheck": true,
"types": ["react", "mocha", "node"]
},
"include": ["src/**/*", "../../node_modules/@mui/monorepo/test/utils/initMatchers.ts"]
Expand Down
2 changes: 0 additions & 2 deletions packages/x-date-pickers/src/DatePicker/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,6 @@ const getFormatAndMaskByViews = <TDate>(
): { disableMaskedInput?: boolean; inputFormat: string; mask?: string } => {
if (isYearOnlyView(views)) {
return {
mask: '____',
inputFormat: utils.formats.year,
};
}
Expand All @@ -94,7 +93,6 @@ const getFormatAndMaskByViews = <TDate>(
}

return {
mask: '__/__/____',
inputFormat: utils.formats.keyboardDate,
};
};
Expand Down
1 change: 0 additions & 1 deletion packages/x-date-pickers/src/DateTimePicker/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,6 @@ export function useDateTimePickerDefaultizedProps<
openTo: 'day',
views: ['year', 'day', 'hours', 'minutes'],
ampmInClock: true,
mask: ampm ? '__/__/____ __:__ _m' : '__/__/____ __:__',
acceptRegex: ampm ? /[\dap]/gi : /\d/gi,
disableMaskedInput: false,
inputFormat: ampm ? utils.formats.keyboardDateTime12h : utils.formats.keyboardDateTime24h,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ describe('<DesktopDatePicker /> keyboard interactions', () => {
const onErrorMock = spy();
// we are running validation on value change
function DatePickerInput() {
const [date, setDate] = React.useState<Date | null>(null);
const [date, setDate] = React.useState<number | Date | null>(null);

return (
<DesktopDatePicker
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const WrappedDesktopDateTimePicker = withPickerControls(DesktopDateTimePicker)({
describe('<DesktopDateTimePicker />', () => {
const { render } = createPickerRenderer({
clock: 'fake',
clockConfig: adapterToUse.date('2018-01-01T00:00:00.000').getTime(),
clockConfig: new Date('2018-01-01T00:00:00.000'),
});

['readOnly', 'disabled'].forEach((prop) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const WrappedMobileDateTimePicker = withPickerControls(MobileDateTimePicker)({
describe('<MobileDateTimePicker />', () => {
const { render } = createPickerRenderer({
clock: 'fake',
clockConfig: adapterToUse.date('2018-01-01T00:00:00.000').getTime(),
clockConfig: new Date('2018-01-01T00:00:00.000'),
});

it('prop: open – overrides open state', () => {
Expand Down
1 change: 0 additions & 1 deletion packages/x-date-pickers/src/TimePicker/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,6 @@ export function useTimePickerDefaultizedProps<
openTo: 'hours',
views: ['hours', 'minutes'],
acceptRegex: ampm ? /[\dapAP]/gi : /\d/gi,
mask: ampm ? '__:__ _m' : '__:__',
disableMaskedInput: false,
getOpenDialogAriaText: getTextFieldAriaText,
inputFormat: ampm ? utils.formats.fullTime12h : utils.formats.fullTime24h,
Expand Down
24 changes: 18 additions & 6 deletions packages/x-date-pickers/src/internals/hooks/useMaskedInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
maskedDateFormatter,
getDisplayDate,
checkMaskIsValidForCurrentFormat,
getMaskFromCurrentFormat,
} from '../utils/text-field-helper';

type MaskedInputProps<TInputDate, TDate> = Omit<
Expand Down Expand Up @@ -43,19 +44,30 @@ export const useMaskedInput = <TInputDate, TDate>({

const formatHelperText = utils.getFormatHelperText(inputFormat);

const shouldUseMaskedInput = React.useMemo(() => {
const { shouldUseMaskedInput, maskToUse } = React.useMemo(() => {
// formatting of dates is a quite slow thing, so do not make useless .format calls
if (!mask || disableMaskedInput) {
return false;
if (disableMaskedInput) {
return { shouldUseMaskedInput: false, maskToUse: '' };
}
const computedMaskToUse = getMaskFromCurrentFormat(mask, inputFormat, acceptRegex, utils);

return checkMaskIsValidForCurrentFormat(mask, inputFormat, acceptRegex, utils);
return {
shouldUseMaskedInput: checkMaskIsValidForCurrentFormat(
computedMaskToUse,
inputFormat,
acceptRegex,
utils,
),
maskToUse: computedMaskToUse,
};
}, [acceptRegex, disableMaskedInput, inputFormat, mask, utils]);

const formatter = React.useMemo(
() =>
shouldUseMaskedInput && mask ? maskedDateFormatter(mask, acceptRegex) : (st: string) => st,
[acceptRegex, mask, shouldUseMaskedInput],
shouldUseMaskedInput && maskToUse
? maskedDateFormatter(maskToUse, acceptRegex)
: (st: string) => st,
[acceptRegex, maskToUse, shouldUseMaskedInput],
);

// TODO: Implement with controlled vs uncontrolled `rawValue`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,18 +26,42 @@ describe('text-field-helper', () => {
});

[
{ mask: '__.__.____', format: adapterToUse.formats.keyboardDate, isValid: false },
{ mask: '__/__/____', format: adapterToUse.formats.keyboardDate, isValid: true },
{ mask: '__:__ _m', format: adapterToUse.formats.fullTime, isValid: false },
{ mask: '__/__/____ __:__ _m', format: adapterToUse.formats.keyboardDateTime, isValid: false },
{ mask: '__/__/____ __:__', format: adapterToUse.formats.keyboardDateTime24h, isValid: true },
{ mask: '__/__/____', format: 'MM/dd/yyyy', isValid: true },
{ mask: '__/__/____', format: 'MMMM yyyy', isValid: false },
// Time picker
// - with ampm = true
{ mask: '__:__ _m', format: adapterToUse.formats.fullTime12h, isValid: true },
// - with ampm=false
{ mask: '__:__', format: adapterToUse.formats.fullTime24h, isValid: true },
// Date Picker
{
mask: '__/__/____',
format: adapterToUse.formats.keyboardDate,
isValid: true,
},
// - with year only
{
mask: '____',
format: adapterToUse.formats.year,
isValid: true,
},
// DateTimePicker
// - with ampm=true
{
mask: '__/__/____ __:__ _m',
format: adapterToUse.formats.keyboardDateTime12h,
isValid: true,
},
// - with ampm=false
{
mask: '__/__/____ __:__',
format: adapterToUse.formats.keyboardDateTime24h,
isValid: true,
},
// Test rejections
{ mask: '__.__.____', format: adapterToUse.formats.keyboardDate, isValid: false },
{ mask: '__:__ _m', format: adapterToUse.formats.fullTime, isValid: false },
{ mask: '__/__/____ __:__ _m', format: adapterToUse.formats.keyboardDateTime, isValid: false },
{ mask: '__/__/____', format: 'MM/dd/yyyy', isValid: adapterToUse.lib === 'date-fns' }, // only pass with date-fns
{ mask: '__/__/____', format: 'MMMM yyyy', isValid: false },
].forEach(({ mask, format, isValid }, index) => {
it(`checkMaskIsValidFormat returns ${isValid} for mask #${index} '${mask}' and format ${format}`, () => {
const runMaskValidation = () =>
Expand All @@ -46,12 +70,7 @@ describe('text-field-helper', () => {
if (isValid) {
expect(runMaskValidation()).to.be.equal(true);
} else {
expect(runMaskValidation).toWarnDev(
[
`The mask "${mask}" you passed is not valid for the format used ${format}.`,
`Falling down to uncontrolled no-mask input.`,
].join('\n'),
);
Comment on lines -49 to -54
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I removed the The mask ... you passed because when masked is inferred, it does not make sens to show this error message to the dev

expect(runMaskValidation).toWarnDev('Falling down to uncontrolled no-mask input.');
}
});
});
Expand Down
68 changes: 56 additions & 12 deletions packages/x-date-pickers/src/internals/utils/text-field-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,55 @@ const MASK_USER_INPUT_SYMBOL = '_';
const staticDateWith2DigitTokens = '2019-11-21T22:30:00.000';
const staticDateWith1DigitTokens = '2019-01-01T09:00:00.000';

export function getMaskFromCurrentFormat(
mask: string | undefined,
format: string,
acceptRegex: RegExp,
utils: MuiPickersAdapter<any>,
) {
if (mask) {
return mask;
}

const formattedDateWith1Digit = utils.formatByString(
utils.date(staticDateWith1DigitTokens)!,
format,
);
const inferredFormatPatternWith1Digits = formattedDateWith1Digit.replace(
acceptRegex,
MASK_USER_INPUT_SYMBOL,
);

const inferredFormatPatternWith2Digits = utils
.formatByString(utils.date(staticDateWith2DigitTokens)!, format)
.replace(acceptRegex, '_');

if (inferredFormatPatternWith1Digits === inferredFormatPatternWith2Digits) {
return inferredFormatPatternWith1Digits;
}

if (process.env.NODE_ENV !== 'production') {
console.warn(
[
`Mask does not support numbers with variable length such as 'M'.`,
`Either use numbers with fix length or disable mask feature with 'disableMaskedInput' prop`,
`Falling down to uncontrolled no-mask input.`,
].join('\n'),
);
}
return '';
}

export function checkMaskIsValidForCurrentFormat(
mask: string,
format: string,
acceptRegex: RegExp,
utils: MuiPickersAdapter<any>,
) {
if (!mask) {
return false;
}

const formattedDateWith1Digit = utils.formatByString(
utils.date(staticDateWith1DigitTokens)!,
format,
Expand All @@ -59,35 +102,36 @@ export function checkMaskIsValidForCurrentFormat(
.replace(acceptRegex, '_');

const isMaskValid =
inferredFormatPatternWith2Digits === mask && inferredFormatPatternWith1Digits === mask;
inferredFormatPatternWith2Digits === inferredFormatPatternWith1Digits &&
mask === inferredFormatPatternWith2Digits;

if (!isMaskValid && utils.lib !== 'luxon' && process.env.NODE_ENV !== 'production') {
const defaultWarning = [
`The mask "${mask}" you passed is not valid for the format used ${format}.`,
`Falling down to uncontrolled no-mask input.`,
];

if (format.includes('MMM')) {
console.warn(
[
...defaultWarning,
`Mask does not support literals such as 'MMM'.`,
`Either use numbers with fix length or disable mask feature with 'disableMaskedInput' prop`,
`Falling down to uncontrolled no-mask input.`,
].join('\n'),
);
} else if (
inferredFormatPatternWith2Digits !== mask &&
inferredFormatPatternWith1Digits === mask
inferredFormatPatternWith2Digits &&
inferredFormatPatternWith2Digits !== inferredFormatPatternWith1Digits
) {
console.warn(
[
...defaultWarning,
`Mask does not support numbers with variable length such as 'M'.`,
`Either use numbers with fix length or disable mask feature with 'disableMaskedInput' prop`,
`Falling down to uncontrolled no-mask input.`,
].join('\n'),
);
} else if (mask) {
console.warn(
[
`The mask "${mask}" you passed is not valid for the format used ${format}.`,
`Falling down to uncontrolled no-mask input.`,
].join('\n'),
);
} else {
console.warn(defaultWarning.join('\n'));
}
}

Expand Down
Loading