Skip to content
New issue

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

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

Already on GitHub? # to your account

[docs] Create Pickers masked field recipe #13515

Merged
merged 56 commits into from
Oct 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
0561039
[docs] Try to create a recipe with a Masked DateField
flaviendelangle Jun 17, 2024
91e822c
Merge
flaviendelangle Jul 12, 2024
f4ecfe2
Merge
flaviendelangle Sep 4, 2024
ffda378
[docs] New recipe of a read-only field
flaviendelangle Sep 13, 2024
81099d2
Fix
flaviendelangle Sep 13, 2024
95075dd
Fix
flaviendelangle Sep 13, 2024
92da0c7
Fix
flaviendelangle Sep 13, 2024
b79b5f4
Add onOpen prop
flaviendelangle Sep 13, 2024
c7a1032
Fix types
flaviendelangle Sep 13, 2024
66574c3
Fix types
flaviendelangle Sep 13, 2024
00c6329
Fix
flaviendelangle Sep 13, 2024
cb32485
Merge branch 'master' into readonly-field
flaviendelangle Sep 17, 2024
15fbfec
Improve doc
flaviendelangle Sep 17, 2024
5768e56
Merge branch 'master' into readonly-field
flaviendelangle Sep 20, 2024
26a2135
Try to move onOpen to a context
flaviendelangle Sep 24, 2024
676aa65
Remove TS changes
flaviendelangle Sep 24, 2024
9f5f302
Fix
flaviendelangle Sep 24, 2024
9837ebf
Fix
flaviendelangle Sep 24, 2024
c9b0906
Work
flaviendelangle Sep 24, 2024
9ca0721
Move logic to usePicker
flaviendelangle Sep 24, 2024
1f185cf
Improve typing
flaviendelangle Sep 24, 2024
5f7f8e5
Work
flaviendelangle Sep 24, 2024
7bd2d32
Review Arthur
flaviendelangle Sep 30, 2024
f345cba
Merge branch 'master' into readonly-field
flaviendelangle Sep 30, 2024
299a96c
Work
flaviendelangle Sep 30, 2024
6482d30
Work
flaviendelangle Sep 30, 2024
a813d22
Merge branch 'readonly-field' into explore-masked-recipe
flaviendelangle Sep 30, 2024
8ac8686
Merge in readonly branch
flaviendelangle Sep 30, 2024
86e3688
Update demo
flaviendelangle Sep 30, 2024
281503e
Remove all demos
flaviendelangle Sep 30, 2024
59f80f9
Work
flaviendelangle Sep 30, 2024
164a1de
Work
flaviendelangle Oct 1, 2024
7adf029
Fix
flaviendelangle Oct 1, 2024
77ef242
Review Lukas
flaviendelangle Oct 4, 2024
a1bc87f
[pickers] Move the DateFieldInPickerProps interface to the picker fol…
flaviendelangle Oct 4, 2024
3642e3c
Fix
flaviendelangle Oct 7, 2024
e6ea1a5
Merge branch 'master' into readonly-field
flaviendelangle Oct 7, 2024
cb91ec7
Merge
flaviendelangle Oct 7, 2024
2365f69
Merge
flaviendelangle Oct 7, 2024
07a0285
Review Lukas + JSDoc for useParsedFormat
flaviendelangle Oct 7, 2024
9d5e300
Replace toggling method with onClose and onOpen
flaviendelangle Oct 7, 2024
065dbfc
Merge branch 'master' into readonly-field
flaviendelangle Oct 7, 2024
f635462
Merge branch 'readonly-field' into explore-masked-recipe
flaviendelangle Oct 7, 2024
2f21b72
Fix
flaviendelangle Oct 7, 2024
d0467e3
Fix
flaviendelangle Oct 7, 2024
545c2d9
Fix
flaviendelangle Oct 7, 2024
1fad212
Fix
flaviendelangle Oct 7, 2024
48d2323
Fix
flaviendelangle Oct 7, 2024
525ff6f
Merge branch 'master' into explore-masked-recipe
flaviendelangle Oct 18, 2024
7744136
Merge branch 'master' into explore-masked-recipe
flaviendelangle Oct 23, 2024
9951d10
Update docs/data/date-pickers/custom-field/custom-field.md
flaviendelangle Oct 25, 2024
ba42c2b
Merge branch 'master' into explore-masked-recipe
flaviendelangle Oct 28, 2024
675ccf5
Merge remote-tracking branch 'origin/explore-masked-recipe' into expl…
flaviendelangle Oct 28, 2024
4c91a18
Code review: Michel + Lukas + Sam
flaviendelangle Oct 28, 2024
695e370
Review: Lukas
flaviendelangle Oct 28, 2024
15b55cc
Review: Sam
flaviendelangle Oct 28, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
Copy link
Member

Choose a reason for hiding this comment

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

nit: not so sure about this ... i definitely isn't needed, but are you intending to only check for a single digit here?

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 kept the regexp that we used in v5.
This code came from the lab and I never really touched it a lot to be honested 😆

https://github.com/mui/mui-x/blob/v5.x/packages/x-date-pickers/src/internals/hooks/useMaskedInput.tsx#L26

But we don't have only digits, we have letters, at least for the meridiem.

Copy link
Member

Choose a reason for hiding this comment

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

oh right ... we could potentially improve this then and use a different regex to exclude anything else: [\d]|(a|p)m


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/"
Copy link
Member

Choose a reason for hiding this comment

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

could we also move the cursor to behind the '/'?
Right now it looks like this: 21|/, but preferably it would do this instead: 21/|

Copy link
Member Author

@flaviendelangle flaviendelangle Oct 25, 2024

Choose a reason for hiding this comment

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

In v5 the cursor was before the /
I do agree that having it after would be better, but I did not find a solution to do it 😬
You can play with the original behavior here: https://v5.mui.com/x/react-date-pickers/date-picker/

Copy link
Member

Choose a reason for hiding this comment

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

Yeah, i thought so ... its not blocking IMO

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.