From 0f310ef75c814b95526a2f91dc54e04bbba089dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20S=C3=A1ros?= Date: Wed, 21 Aug 2024 17:02:55 +0200 Subject: [PATCH] feat(ui-date-input): improve DateInput2 api, extend docs --- .../ui-date-input/src/DateInput/README.md | 2 +- .../ui-date-input/src/DateInput2/README.md | 346 +++++++++--------- .../ui-date-input/src/DateInput2/index.tsx | 239 +++++++----- .../ui-date-input/src/DateInput2/props.ts | 59 +-- 4 files changed, 351 insertions(+), 295 deletions(-) diff --git a/packages/ui-date-input/src/DateInput/README.md b/packages/ui-date-input/src/DateInput/README.md index 409ab327f3..0387d77ada 100644 --- a/packages/ui-date-input/src/DateInput/README.md +++ b/packages/ui-date-input/src/DateInput/README.md @@ -2,7 +2,7 @@ describes: DateInput --- -> **Important:** You can now use are updated version [`DateInput2`](/#DateInput2) which is easier to configure for developers, has a better UX, better accessibility features and a year picker. We recommend using that instead of `DateInput` which will be deprecated in the future. +> *Note:* you can now try the updated (but still experimental) [`DateInput2`](/#DateInput2) which is easier to configure for developers, has a better UX, better accessibility features and a year picker. We recommend using that instead of `DateInput` which will be deprecated in the future. The `DateInput` component provides a visual interface for inputting date data. diff --git a/packages/ui-date-input/src/DateInput2/README.md b/packages/ui-date-input/src/DateInput2/README.md index 86c7a2d837..0ab27dc355 100644 --- a/packages/ui-date-input/src/DateInput2/README.md +++ b/packages/ui-date-input/src/DateInput2/README.md @@ -2,28 +2,37 @@ describes: DateInput2 --- -This component is an updated version of [`DateInput`](/#DateInput) that's easier to configure for developers, has a better UX, better accessibility features and a year picker. We recommend using this instead of `DateInput` which will be deprecated in the future. +> *Warning*: `DateInput2` is an **experimental** upgrade to the existing [`DateInput`](/#DateInput) component, offering easier configuration, better UX, improved accessibility, and a year picker. While it addresses key limitations of `DateInput`, it's still in the experimental phase, with some missing unit tests and potential API changes. ### Minimal config - ```js class Example extends React.Component { - state = { value: '' } + state = { inputValue: '', dateString: '' } render() { return ( - this.setState({ value })} - invalidDateErrorMessage="Invalid date" - /> +
+ { + this.setState({ dateString, inputValue }) + }} + invalidDateErrorMessage="Invalid date" + /> +

+ Input Value: {this.state.inputValue} +
+ UTC Date String: {this.state.dateString} +

+
) } } @@ -33,8 +42,58 @@ This component is an updated version of [`DateInput`](/#DateInput) that's easier - ```js const Example = () => { - const [value, setValue] = useState('') + const [inputValue, setInputValue] = useState('') + const [dateString, setDateString] = useState('') return ( +
+ { + setInputValue(inputValue) + setDateString(dateString) + }} + invalidDateErrorMessage="Invalid date" + /> +

+ Input Value: {inputValue} +
+ UTC Date String: {dateString} +

+
+ ) + } + + render() + ``` + +### Parsing and formatting dates + +When typing in a date manually (instead of using the included picker), the component tries to parse the date as you type it in. By default parsing is based on the user's locale which determines the order of day, month and year (e.g.: a user with US locale will have MONTH/DAY/YEAR order, and someone with GB locale will have DAY/MONTH/YEAR order). + +Any of the following separators can be used when typing a date: `,`, `-`, `.`, `/` or a whitespace however on blur the date will be formatted according to the locale and separators will be changed and leading zeros also adjusted. + +If you want different parsing and formatting then the current locale you can use the `dateFormat` prop which accepts either a string with a name of a different locale (so you can use US date format even if the user is France) or a parser and formatter functions. + +The default parser also have a limitation of excluding years before `1000` and after `9999`. These values are invalid by default but not with custom parsers. + +```js +--- +type: example +--- +const Example = () => { + const [value, setValue] = useState('') + const [value2, setValue2] = useState('') + + return ( +
+

US locale with german date format:

setValue(value)} - invalidDateErrorMessage="Invalid date" /> - ) - } +

US locale with ISO date format:

+ { + // split input on '.', whitespace, '/', ',' or '-' using regex: /[.\s/.-]+/ + // the '+' allows splitting on consecutive delimiters + const [year, month, day] = input.split(/[,.\s/.-]+/) + const newDate = new Date(year, month-1, day) + return isNaN(newDate) ? '' : newDate + }, + formatter: (date) => { + // vanilla js formatter but you could use a date library instead + const year = date.getFullYear() + // month is zero indexed so add 1 + const month = `${date.getMonth() + 1}`.padStart(2, '0') + const day = `${date.getDate()}`.padStart(2, '0') + return `${year}-${month}-${day}` + } + }} + onChange={(e, value) => setValue2(value)} + /> +
+ ) +} - render() - ``` +render() +``` + +### Timezones + +In the examples above you can see that the `onChange` callback also return a UTC date string. This means it is timezone adjusted. If the timezone is not set via the `timezone` prop, it is calculated/assumed from the user's machine. So if a user chooses September 10th 2024 with the timezone 'Europe/Budapest', the `onChange` function will return `2024-09-09T22:00:00.000Z` because Budapest is two hours ahead of UTC (summertime). ### With year picker - ```js class Example extends React.Component { - state = { value: '' } + state = { inputValue: '', dateString: '' } render() { return ( - this.setState({ value })} - invalidDateErrorMessage="Invalid date" - withYearPicker={{ - screenReaderLabel: 'Year picker', - startYear: 1900, - endYear: 2024 - }} - /> +
+ { + this.setState({ dateString, inputValue }) + }} + invalidDateErrorMessage="Invalid date" + withYearPicker={{ + screenReaderLabel: 'Year picker', + startYear: 1900, + endYear: 2024 + }} + /> +

+ Input Value: {this.state.inputValue} +
+ UTC Date String: {this.state.dateString} +

+
) } } @@ -87,26 +191,36 @@ This component is an updated version of [`DateInput`](/#DateInput) that's easier - ```js const Example = () => { - const [value, setValue] = useState('') - + const [inputValue, setInputValue] = useState('') + const [dateString, setDateString] = useState('') return ( - setValue(value)} - invalidDateErrorMessage="Invalid date" - withYearPicker={{ - screenReaderLabel: 'Year picker', - startYear: 1900, - endYear: 2024 - }} - /> +
+ { + setInputValue(inputValue) + setDateString(dateString) + }} + invalidDateErrorMessage="Invalid date" + withYearPicker={{ + screenReaderLabel: 'Year picker', + startYear: 1900, + endYear: 2024 + }} + /> +

+ Input Value: {inputValue} +
+ UTC Date String: {dateString} +

+
) } @@ -115,9 +229,9 @@ This component is an updated version of [`DateInput`](/#DateInput) that's easier ### Date validation -By default `DateInput2` only does date validation if the `invalidDateErrorMessage` prop is provided. This uses the browser's `Date` object to try an parse the user provided date and displays the error message if it fails. Validation is only triggered on the blur event of the input field. +By default `DateInput2` only does date validation if the `invalidDateErrorMessage` prop is provided. Validation is triggered on the blur event of the input field. Invalid dates are determined based on [parsing](/#DateInput2/#Parsing%20and%20formatting%20dates). -If you want to do a more complex validation than the above (e.g. only allow a subset of dates) you can use the `onRequestValidateDate` prop to pass a validation function. This function will run on blur or on selecting the date from the picker. The result of the internal validation will be passed to this function. Then you have to set the error messages accordingly. Check the following example for more details: +If you want to do a more complex validation than the above (e.g. only allow a subset of dates) you can use the `onBlur` and `messages` props. ```js --- @@ -125,18 +239,22 @@ type: example --- const Example = () => { const [value, setValue] = useState('') + const [dateString, setDateString] = useState('') const [messages, setMessages] = useState([]) - const handleDateValidation = (dateString, isValidDate) => { - if (!isValidDate) { + const handleDateValidation = (e, inputValue, utcIsoDate) => { + const date = new Date(utcIsoDate) + console.log(utcIsoDate) + // don't validate empty input + if (!utcIsoDate && inputValue.length > 0) { setMessages([{ type: 'error', text: 'This is not a valid date' }]) - } else if (new Date(dateString) < new Date('January 1, 1900')) { + } else if (date < new Date('1990-01-01')) { setMessages([{ type: 'error', - text: 'Use date after January 1, 1900' + text: 'Select date after January 1, 1990' }]) } else { setMessages([]) @@ -145,7 +263,7 @@ const Example = () => { return ( { render() ``` - -### Date formatting - -The display format of the dates can be set via the `formatDate` property. It will be applied if the user clicks on a date in the date picker of after blur event from the input field. -Something to pay attention to is that the date string passed back in the callback function **is in UTC timezone**. - -```js ---- -type: example ---- -const Example = () => { - const [value1, setValue1] = useState('') - const [value2, setValue2] = useState('') - const [value3, setValue3] = useState('') - - const shortDateFormatFn = (dateString, locale, timezone) => { - return new Date(dateString).toLocaleDateString(locale, { - month: 'numeric', - year: 'numeric', - day: 'numeric', - timeZone: timezone, - }) - } - - const isoDateFormatFn = (dateString, locale, timezone) => { - // this is a simple way to get ISO8601 date in a specific timezone but should not be used in production - // please use a proper date library instead like date-fns, luxon or dayjs - const localeDate = new Date(dateString).toLocaleDateString('sv', { - month: 'numeric', - year: 'numeric', - day: 'numeric', - timeZone: timezone, - }) - - return localeDate - } - - return ( -
- setValue1(value)} - withYearPicker={{ - screenReaderLabel: 'Year picker', - startYear: 1900, - endYear: 2024 - }} - /> - setValue2(value)} - formatDate={shortDateFormatFn} - withYearPicker={{ - screenReaderLabel: 'Year picker', - startYear: 1900, - endYear: 2024 - }} - /> - setValue3(value)} - formatDate={isoDateFormatFn} - withYearPicker={{ - screenReaderLabel: 'Year picker', - startYear: 1900, - endYear: 2024 - }} - /> -
- ) -} - -render() -``` diff --git a/packages/ui-date-input/src/DateInput2/index.tsx b/packages/ui-date-input/src/DateInput2/index.tsx index 9f56e5ac68..fe7579ba4a 100644 --- a/packages/ui-date-input/src/DateInput2/index.tsx +++ b/packages/ui-date-input/src/DateInput2/index.tsx @@ -44,35 +44,91 @@ import type { DateInput2Props } from './props' import type { FormMessage } from '@instructure/ui-form-field' import type { Moment } from '@instructure/ui-i18n' -function parseDate(dateString: string): string { - const date = new Date(dateString) - return isNaN(date.getTime()) ? '' : date.toISOString() -} +function parseLocaleDate(dateString: string = '', locale: string, timeZone: string): Date | null { + // This function may seem complicated but it basically does one thing: + // Given a dateString, a locale and a timeZone. The dateString is assumed to be formatted according + // to the locale. So if the locale is `en-us` the dateString is expected to be in the format of M/D/YYYY. + // The dateString is also assumed to be in the given timeZone, so "1/1/2020" in "America/Los_Angeles" timezone is + // expected to be "2020-01-01T08:00:00.000Z" in UTC time. + // This function tries to parse the dateString taking these variables into account and return a javascript Date object + // that is adjusted to be in UTC. + + // Split string on '.', whitespace, '/', ',' or '-' using regex: /[.\s/.-]+/. + // The '+' allows splitting on consecutive delimiters. + // `.filter(Boolean)` is needed because some locales have a delimeter at the end (e.g.: "hu-hu") + const splitDate = dateString.split(/[,.\s/.-]+/).filter(Boolean) + + // splitDate should only contain 3 values: year, month and day + if (splitDate.length !== 3) return null -function defaultDateFormatter( - dateString: string, - locale: string, - timezone: string -) { - return new Date(dateString).toLocaleDateString(locale, { - month: 'long', - year: 'numeric', - day: 'numeric', - timeZone: timezone + // create a locale formatted new date to later extract the order and delimeter information + const localeDate = new Intl.DateTimeFormat(locale).formatToParts(new Date()) + + let index = 0 + let day: number | undefined, month: number | undefined, year: number | undefined + localeDate.forEach((part) => { + if (part.type === 'month') { + month = parseInt(splitDate[index], 10) + index++ + } else if (part.type === 'day') { + day = parseInt(splitDate[index], 10) + index++ + } else if (part.type === 'year') { + year = parseInt(splitDate[index], 10) + index++ + } }) + + // sensible year limitations + if (!year || !month || !day || year < 1000 || year > 9999) return null + + // create utc date from year, month (zero indexed) and day + const date = new Date(Date.UTC(year, month - 1, day)) + + // Format date string in the provided timezone + const parts = new Intl.DateTimeFormat('en-US', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }).formatToParts(date) + + // Extract the date and time parts from the formatted string + const dateStringInTimezone: { + [key: string]: number + } = parts.reduce((acc, part) => { + return part.type === 'literal' ? acc : { + ...acc, + [part.type]: part.value, + } + }, {}) + + // Create a date string in the format 'YYYY-MM-DDTHH:mm:ss' + const dateInTimezone = `${dateStringInTimezone.year}-${dateStringInTimezone.month}-${dateStringInTimezone.day}T${dateStringInTimezone.hour}:${dateStringInTimezone.minute}:${dateStringInTimezone.second}` + + // Calculate time difference for timezone offset + const timeDiff = new Date(dateInTimezone + 'Z').getTime() - date.getTime() + const newTime = new Date(date.getTime() - timeDiff) + // Return the UTC Date corresponding to the time in the specified timezone + return newTime } /** --- category: components --- + +@module experimental **/ const DateInput2 = ({ renderLabel, screenReaderLabels, isRequired = false, interaction = 'enabled', - size = 'medium', isInline = false, value, messages, @@ -80,46 +136,84 @@ const DateInput2 = ({ onChange, onBlur, withYearPicker, - onRequestValidateDate, invalidDateErrorMessage, locale, timezone, placeholder, - formatDate = defaultDateFormatter, + dateFormat, + onRequestValidateDate, // margin, TODO enable this prop ...rest }: DateInput2Props) => { - const [selectedDate, setSelectedDate] = useState('') + const localeContext = useContext(ApplyLocaleContext) + + const getLocale = () => { + if (locale) { + return locale + } else if (localeContext.locale) { + return localeContext.locale + } + // default to the system's locale + return Locale.browserLocale() + } + + const getTimezone = () => { + if (timezone) { + return timezone + } else if (localeContext.timezone) { + return localeContext.timezone + } + // default to the system's timezone + return Intl.DateTimeFormat().resolvedOptions().timeZone + } + const [inputMessages, setInputMessages] = useState( messages || [] ) const [showPopover, setShowPopover] = useState(false) - const localeContext = useContext(ApplyLocaleContext) - - useEffect(() => { - // when `value` is changed, validation removes the error message if passes - // but it's NOT adding error message if validation fails for better UX - validateInput(true) - }, [value]) useEffect(() => { setInputMessages(messages || []) }, [messages]) useEffect(() => { - setSelectedDate(parseDate(value || '')) - }, []) + const [, utcIsoDate] = parseDate(value) + // clear error messages if date becomes valid + if (utcIsoDate || !value) { + setInputMessages(messages || []) + } + }, [value]) - const handleInputChange = ( - e: SyntheticEvent, - newValue: string, - parsedDate: string = '' - ) => { - // blur event formats the input which shouldn't trigger parsing - if (e.type !== 'blur') { - setSelectedDate(parseDate(newValue)) + const parseDate = (dateString: string = ''): [string, string] => { + let date: Date | null = null + if (dateFormat) { + if (typeof dateFormat === 'string') { + // use dateFormat instead of the user locale + date = parseLocaleDate(dateString, dateFormat, getTimezone()) + } else if (dateFormat.parser) { + date = dateFormat.parser(dateString) + } + } else { + // no dateFormat prop passed, use locale for formatting + date = parseLocaleDate(dateString, getLocale(), getTimezone()) } - onChange?.(e, newValue, parsedDate) + return date ? [formatDate(date), date.toISOString()] : ['', ''] + } + + const formatDate = (date: Date): string => { + if (typeof dateFormat === 'string') { + // use dateFormat instead of the user locale + return date.toLocaleDateString(dateFormat, {timeZone: getTimezone()}) + } else if (dateFormat?.formatter) { + return dateFormat.formatter(date) + } + // no dateFormat prop passed, use locale for formatting + return date.toLocaleDateString(getLocale(), {timeZone: getTimezone()}) + } + + const handleInputChange = (e: SyntheticEvent, newValue: string) => { + const [, utcIsoDate] = parseDate(newValue) + onChange?.(e, newValue, utcIsoDate) } const handleDateSelected = ( @@ -127,67 +221,32 @@ const DateInput2 = ({ _momentDate: Moment, e: SyntheticEvent ) => { - const formattedDate = formatDate(dateString, getLocale(), getTimezone()) - const parsedDate = parseDate(dateString) - setSelectedDate(parsedDate) - handleInputChange(e, formattedDate, parsedDate) setShowPopover(false) - onRequestValidateDate?.(dateString, true) + const newValue = formatDate(new Date(dateString)) + onChange?.( + e, + newValue, + dateString + ) + onRequestValidateDate?.(e, newValue, dateString) } - // onlyRemoveError is used to remove the error msg immediately when the user inputs a valid date (and don't wait for blur event) - const validateInput = (onlyRemoveError = false): boolean => { - // don't validate empty input - if (!value || parseDate(value) || selectedDate) { - setInputMessages(messages || []) - return true - } - // only show error if there is no user provided validation callback - if ( - !onlyRemoveError && - typeof invalidDateErrorMessage === 'string' && - !onRequestValidateDate - ) { + const handleBlur = (e: SyntheticEvent) => { + const [localeDate, utcIsoDate] = parseDate(value) + if (localeDate) { + if (localeDate !== value) { + onChange?.(e, localeDate, utcIsoDate) + } + } else if (value && invalidDateErrorMessage) { setInputMessages([ - { - type: 'error', - text: invalidDateErrorMessage - } + {type: 'error', text: invalidDateErrorMessage} ]) } - - return false - } - - const getLocale = () => { - if (locale) { - return locale - } else if (localeContext.locale) { - return localeContext.locale - } - return Locale.browserLocale() - } - - const getTimezone = () => { - if (timezone) { - return timezone - } else if (localeContext.timezone) { - return localeContext.timezone - } - // default to the system's timezone - return Intl.DateTimeFormat().resolvedOptions().timeZone - } - - const handleBlur = (e: SyntheticEvent) => { - const isInputValid = validateInput(false) - if (isInputValid && selectedDate) { - const formattedDate = formatDate(selectedDate, getLocale(), getTimezone()) - handleInputChange(e, formattedDate, selectedDate) - } - onRequestValidateDate?.(value, isInputValid) - onBlur?.(e) + onRequestValidateDate?.(e, value || '', utcIsoDate) + onBlur?.(e, value || '', utcIsoDate) } + const selectedDate = parseDate(value)[1] return ( diff --git a/packages/ui-date-input/src/DateInput2/props.ts b/packages/ui-date-input/src/DateInput2/props.ts index 71e0732e56..dbe92693ac 100644 --- a/packages/ui-date-input/src/DateInput2/props.ts +++ b/packages/ui-date-input/src/DateInput2/props.ts @@ -45,13 +45,9 @@ type DateInput2OwnProps = { nextMonthButton: string } /** - * Specifies the input value. + * Specifies the input value *before* formatting. The `formatDate` will be applied to it before displaying. Should be a valid, parsable date. */ - value?: string // TODO: controllable(PropTypes.string) - /** - * Specifies the input size. - */ - size?: 'small' | 'medium' | 'large' + value?: string /** * Html placeholder text to display when the input has no value. This should * be hint text, not a label replacement. @@ -62,13 +58,13 @@ type DateInput2OwnProps = { */ onChange?: ( event: React.SyntheticEvent, - inputValue: string, - dateString: string + value: string, + utcDateString: string ) => void /** * Callback executed when the input fires a blur event. */ - onBlur?: (event: React.SyntheticEvent) => void + onBlur?: (event: React.SyntheticEvent, value: string, utcDateString: string) => void /** * Specifies if interaction with the input is enabled, disabled, or readonly. * When "disabled", the input changes visibly to indicate that it cannot @@ -98,24 +94,6 @@ type DateInput2OwnProps = { * }` */ messages?: FormMessage[] - /** - * Callback fired requesting the calendar be shown. - */ - onRequestShowCalendar?: (event: SyntheticEvent) => void - /** - * Callback fired requesting the calendar be hidden. - */ - onRequestHideCalendar?: (event: SyntheticEvent) => void - /** - * Callback fired when the input is blurred. Feedback should be provided - * to the user when this function is called if the selected date or input - * value is invalid. The component has an internal check whether the date can - * be parsed to a valid date. - */ - onRequestValidateDate?: ( - value?: string, - internalValidationPassed?: boolean - ) => void | FormMessage[] /** * The message shown to the user when the date is invalid. If this prop is not set, validation is bypassed. * If it's set to an empty string, validation happens and the input border changes to red if validation hasn't passed. @@ -165,18 +143,19 @@ type DateInput2OwnProps = { startYear: number endYear: number } - /** - * Formatting function for how the date should be displayed inside the input field. It will be applied if the user clicks on a date in the date picker of after blur event from the input field. - */ - formatDate?: (isoDate: string, locale: string, timezone: string) => string + * By default the date format is determined by the locale but can be changed via this prop to an alternate locale (passing it in as a string) or a custom parser and formatter (both as functions) + */ + dateFormat?: { + parser: (input: string) => Date + formatter: (date: Date) => string + } | string /** - * Valid values are `0`, `none`, `auto`, `xxx-small`, `xx-small`, `x-small`, - * `small`, `medium`, `large`, `x-large`, `xx-large`. Apply these values via - * familiar CSS-like shorthand. For example: `margin="small auto large"`. + * Callback executed when the input fires a blur event or a date is selected from the picker. */ - // margin?: Spacing TODO enable this prop + onRequestValidateDate?: (event: React.SyntheticEvent, value: string, utcDateString: string) => void + // margin?: Spacing // TODO enable this prop } type PropKeys = keyof DateInput2OwnProps @@ -191,7 +170,6 @@ const propTypes: PropValidators = { renderLabel: PropTypes.oneOfType([PropTypes.node, PropTypes.func]).isRequired, screenReaderLabels: PropTypes.object.isRequired, value: controllable(PropTypes.string), - size: PropTypes.oneOf(['small', 'medium', 'large']), placeholder: PropTypes.string, onChange: PropTypes.func, onBlur: PropTypes.func, @@ -200,9 +178,6 @@ const propTypes: PropValidators = { isInline: PropTypes.bool, width: PropTypes.string, messages: PropTypes.arrayOf(FormPropTypes.message), - onRequestShowCalendar: PropTypes.func, - onRequestHideCalendar: PropTypes.func, - onRequestValidateDate: PropTypes.func, invalidDateErrorMessage: PropTypes.oneOfType([ PropTypes.func, PropTypes.string @@ -210,7 +185,11 @@ const propTypes: PropValidators = { locale: PropTypes.string, timezone: PropTypes.string, withYearPicker: PropTypes.object, - formatDate: PropTypes.func + dateFormat: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.object, + ]), + onRequestValidateDate: PropTypes.func, } export type { DateInput2Props }