-
-
Notifications
You must be signed in to change notification settings - Fork 1.4k
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
Changes from all commits
0561039
91e822c
f4ecfe2
ffda378
81099d2
95075dd
92da0c7
b79b5f4
c7a1032
66574c3
00c6329
cb32485
15fbfec
5768e56
26a2135
676aa65
9f5f302
9837ebf
c9b0906
9ca0721
1f185cf
5f7f8e5
7bd2d32
f345cba
299a96c
6482d30
a813d22
8ac8686
86e3688
281503e
59f80f9
164a1de
7adf029
77ef242
a1bc87f
3642e3c
e6ea1a5
cb91ec7
2365f69
07a0285
9d5e300
065dbfc
f635462
2f21b72
d0467e3
545c2d9
1fad212
48d2323
525ff6f
7744136
9951d10
ba42c2b
675ccf5
4c91a18
695e370
15b55cc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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/" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. could we also move the cursor to behind the '/'? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In v5 the cursor was before the There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 /> |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
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?There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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