diff --git a/packages/ui-calendar/src/Calendar/index.tsx b/packages/ui-calendar/src/Calendar/index.tsx index 6c05fc0394..df960501f6 100644 --- a/packages/ui-calendar/src/Calendar/index.tsx +++ b/packages/ui-calendar/src/Calendar/index.tsx @@ -145,22 +145,34 @@ class Calendar extends Component { } get hasPrevMonth() { + // this is needed for locales that doesn't use the latin script for numbers e.g.: arabic + const yearNumber = Number( + this.state.visibleMonth + .clone() + .locale('en') + .subtract({ months: 1 }) + .format('YYYY') + ) return ( !this.props.withYearPicker || (this.props.withYearPicker && - Number( - this.state.visibleMonth.clone().subtract({ months: 1 }).format('YYYY') - ) >= this.props.withYearPicker.startYear) + yearNumber >= this.props.withYearPicker.startYear) ) } get hasNextMonth() { + // this is needed for locales that doesn't use the latin script for numbers e.g.: arabic + const yearNumber = Number( + this.state.visibleMonth + .clone() + .locale('en') + .add({ months: 1 }) + .format('YYYY') + ) return ( !this.props.withYearPicker || (this.props.withYearPicker && - Number( - this.state.visibleMonth.clone().add({ months: 1 }).format('YYYY') - ) <= this.props.withYearPicker.endYear) + yearNumber <= this.props.withYearPicker.endYear) ) } @@ -227,16 +239,21 @@ class Calendar extends Component { handleYearChange = ( e: React.SyntheticEvent, - year: number + year: string ) => { const { withYearPicker } = this.props const { visibleMonth } = this.state + const yearNumber = Number( + DateTime.parse(year, this.locale(), this.timezone()) + .locale('en') + .format('YYYY') + ) const newDate = visibleMonth.clone() if (withYearPicker?.onRequestYearChange) { - withYearPicker.onRequestYearChange(e, year) + withYearPicker.onRequestYearChange(e, yearNumber) return } - newDate.year(year) + newDate.year(yearNumber) this.setState({ visibleMonth: newDate }) } @@ -261,12 +278,19 @@ class Calendar extends Component { ...(prevButton || nextButton ? [styles?.navigationWithButtons] : []) ] - const yearList: number[] = [] + const yearList: string[] = [] if (withYearPicker) { const { startYear, endYear } = withYearPicker for (let year = endYear; year >= startYear!; year--) { - yearList.push(year) + // add the years to the list with the correct locale + yearList.push( + DateTime.parse( + year.toString(), + this.locale(), + this.timezone() + ).format('YYYY') + ) } } @@ -294,8 +318,9 @@ class Calendar extends Component { , { @@ -304,7 +329,7 @@ class Calendar extends Component { value?: string | number | undefined id?: string | undefined } - ) => this.handleYearChange(e, Number(value))} + ) => this.handleYearChange(e, `${value}`)} > {yearList.map((year) => ( @@ -441,7 +466,7 @@ class Calendar extends Component { return DateTime.browserTimeZone() } - // date is returned es a ISO string, like 2021-09-14T22:00:00.000Z + // date is returned as an ISO string, like 2021-09-14T22:00:00.000Z handleDayClick = (event: MouseEvent, { date }: { date: string }) => { if (this.props.onDateSelected) { const parsedDate = DateTime.parse(date, this.locale(), this.timezone()) diff --git a/packages/ui-date-input/src/DateInput/README.md b/packages/ui-date-input/src/DateInput/README.md index 409ab327f3..0eb02cf435 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 31f845f0c2..624f0f76a8 100644 --- a/packages/ui-date-input/src/DateInput2/README.md +++ b/packages/ui-date-input/src/DateInput2/README.md @@ -2,16 +2,50 @@ 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({ dateString, inputValue }) + }} + invalidDateErrorMessage="Invalid date" + /> +

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

+
+ ) + } + } + + render() + ``` + +- ```js + const Example = () => { + const [inputValue, setInputValue] = useState('') + const [dateString, setDateString] = useState('') + return ( +
this.setState({ value })} + onChange={(e, inputValue, dateString) => { + setInputValue(inputValue) + setDateString(dateString) + }} invalidDateErrorMessage="Invalid date" /> - ) - } +

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

+
+ ) } render() ``` -- ```js - const Example = () => { - const [value, setValue] = useState('') - return ( +### 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 has a limitation of not working with 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('') + const [value3, setValue3] = useState('') + + return ( +
+

US locale with default format:

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

US locale with german date format:

+ setValue2(value)} + /> +

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) => setValue3(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({ dateString, inputValue }) + }} + invalidDateErrorMessage="Invalid date" + withYearPicker={{ + screenReaderLabel: 'Year picker', + startYear: 1900, + endYear: 2024 + }} + /> +

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

+
+ ) + } + } + + render() + ``` + +- ```js + const Example = () => { + const [inputValue, setInputValue] = useState('') + const [dateString, setDateString] = useState('') + return ( +
this.setState({ value })} + onChange={(e, inputValue, dateString) => { + setInputValue(inputValue) + setDateString(dateString) + }} invalidDateErrorMessage="Invalid date" withYearPicker={{ screenReaderLabel: 'Year picker', @@ -78,42 +229,23 @@ This component is an updated version of [`DateInput`](/#DateInput) that's easier endYear: 2024 }} /> - ) - } +

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

+
+ ) } render() ``` -- ```js - const Example = () => { - const [value, setValue] = useState('') - - return ( - setValue(value)} - invalidDateErrorMessage="Invalid date" - withYearPicker={{ - screenReaderLabel: 'Year picker', - startYear: 1900, - endYear: 2024 - }} - /> - ) - } +### Date validation - render() - ``` +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 current locale. -### With custom validation +If you want to do more complex validation (e.g. only allow a subset of dates) you can use the `onRequestValidateDate` and `messages` props. ```js --- @@ -121,18 +253,24 @@ 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) => { + // utcIsoDate will be an empty string if the input cannot be parsed as a date + + const date = new Date(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([]) @@ -141,7 +279,7 @@ const Example = () => { return ( { render() ``` + +### Date format hint + +If the `placeholder` property is undefined it will display a hint for the date format (like `DD/MM/YYYY`). Usually it is recommended to leave it as it is for a better user experience. diff --git a/packages/ui-date-input/src/DateInput2/index.tsx b/packages/ui-date-input/src/DateInput2/index.tsx index 61c575dfd2..e3b94aa603 100644 --- a/packages/ui-date-input/src/DateInput2/index.tsx +++ b/packages/ui-date-input/src/DateInput2/index.tsx @@ -25,7 +25,6 @@ /** @jsx jsx */ import { useState, useEffect, useContext } from 'react' import type { SyntheticEvent } from 'react' -import moment from 'moment-timezone' import { Calendar } from '@instructure/ui-calendar' import { IconButton } from '@instructure/ui-buttons' import { @@ -43,38 +42,103 @@ import { jsx } from '@instructure/emotion' import { propTypes } from './props' import type { DateInput2Props } from './props' import type { FormMessage } from '@instructure/ui-form-field' +import type { Moment } from '@instructure/ui-i18n' -function isValidDate(dateString: string): boolean { - return !isNaN(new Date(dateString).getTime()) -} - -function isValidMomentDate( - dateString: string, +function parseLocaleDate( + dateString: string = '', locale: string, - timezone: string -): boolean { - return moment - .tz( - dateString, - [moment.ISO_8601, 'llll', 'LLLL', 'lll', 'LLL', 'll', 'LL', 'l', 'L'], - locale, - true, - timezone - ) - .isValid() + 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.: hungarian dates are formatted as `2024. 09. 19.`) + const splitDate = dateString.split(/[,.\s/.-]+/).filter(Boolean) + + // 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 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)) + + if (date.getMonth() !== month - 1 || date.getDate() !== day) { + // Check if the Date object adjusts the values. If it does, the input is invalid. + return null + } + + // Format date string in the provided timezone. The locale here is irrelevant, we only care about how to time is adjusted for the 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 utcTime = new Date(date.getTime() - timeDiff) + // Return the UTC Date corresponding to the time in the specified timezone + return utcTime } /** --- category: components --- + +@module experimental **/ const DateInput2 = ({ renderLabel, screenReaderLabels, isRequired = false, interaction = 'enabled', - size = 'medium', isInline = false, value, messages, @@ -82,80 +146,24 @@ const DateInput2 = ({ onChange, onBlur, withYearPicker, - onRequestValidateDate, invalidDateErrorMessage, locale, timezone, placeholder, + dateFormat, + onRequestValidateDate, + // margin, TODO enable this prop ...rest }: DateInput2Props) => { - const [selectedDate, setSelectedDate] = useState('') - const [inputMessages, setInputMessages] = useState( - messages || [] - ) - const [showPopover, setShowPopover] = useState(false) const localeContext = useContext(ApplyLocaleContext) - useEffect(() => { - validateInput(true) - }, [value]) - - useEffect(() => { - setInputMessages(messages || []) - }, [messages]) - - const handleInputChange = (e: SyntheticEvent, value: string) => { - onChange?.(e, value) - } - - const handleDateSelected = ( - dateString: string, - _momentDate: any, // real type is Moment but used `any` to avoid importing the moment lib - e: SyntheticEvent - ) => { - const formattedDate = new Date(dateString).toLocaleDateString(getLocale(), { - month: 'long', - year: 'numeric', - day: 'numeric', - timeZone: getTimezone() - }) - handleInputChange(e, formattedDate) - setShowPopover(false) - onRequestValidateDate?.(formattedDate, true) - } - - const validateInput = (onlyRemoveError = false): boolean => { - // TODO `isValidDate` and `isValidMomentDate` basically have the same functionality but the latter is a bit more strict (e.g.: `33` is only valid in `isValidMomentDate`) - // in the future we should get rid of moment but currently Calendar is using it for validation too so we can only remove it simultaneously - // otherwise DateInput could pass invalid dates to Calendar and break it - if ( - (isValidDate(value || '') && - isValidMomentDate(value || '', getLocale(), getTimezone())) || - value === '' - ) { - setSelectedDate(value || '') - setInputMessages(messages || []) - return true - } - if (!onlyRemoveError && typeof invalidDateErrorMessage === 'string') { - setInputMessages((messages) => [ - { - type: 'error', - text: invalidDateErrorMessage - }, - ...messages - ]) - } - - return false - } - const getLocale = () => { if (locale) { return locale } else if (localeContext.locale) { return localeContext.locale } + // default to the system's locale return Locale.browserLocale() } @@ -169,32 +177,114 @@ const DateInput2 = ({ return Intl.DateTimeFormat().resolvedOptions().timeZone } + const [inputMessages, setInputMessages] = useState( + messages || [] + ) + const [showPopover, setShowPopover] = useState(false) + + useEffect(() => { + // don't set input messages if there is an error set already + if (!inputMessages) { + setInputMessages(messages || []) + } + }, [messages]) + + useEffect(() => { + const [, utcIsoDate] = parseDate(value) + // clear error messages if date becomes valid + if (utcIsoDate || !value) { + setInputMessages(messages || []) + } + }, [value]) + + 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()) + } + return date ? [formatDate(date), date.toISOString()] : ['', ''] + } + + const formatDate = (date: Date): string => { + // use formatter function if provided + if (typeof dateFormat !== 'string' && dateFormat?.formatter) { + return dateFormat.formatter(date) + } + // if dateFormat set to a locale, use that, otherwise default to the user's locale + return date.toLocaleDateString( + typeof dateFormat === 'string' ? dateFormat : getLocale(), + { timeZone: getTimezone(), calendar: 'gregory', numberingSystem: 'latn' } + ) + } + + const getDateFromatHint = () => { + const exampleDate = new Date('2024-09-01') + const formattedDate = formatDate(exampleDate) + + // Create a regular expression to find the exact match of the number + const regex = (n: string) => { + return new RegExp(`(? 'Y'.repeat(match.length)) + .replace(regex(month), (match) => 'M'.repeat(match.length)) + .replace(regex(day), (match) => 'D'.repeat(match.length)) + } + + const handleInputChange = (e: SyntheticEvent, newValue: string) => { + const [, utcIsoDate] = parseDate(newValue) + onChange?.(e, newValue, utcIsoDate) + } + + const handleDateSelected = ( + dateString: string, + _momentDate: Moment, + e: SyntheticEvent + ) => { + setShowPopover(false) + const newValue = formatDate(new Date(dateString)) + onChange?.(e, newValue, dateString) + onRequestValidateDate?.(e, newValue, dateString) + } + const handleBlur = (e: SyntheticEvent) => { - const isInputValid = validateInput(false) - if (isInputValid && value) { - const formattedDate = new Date(value).toLocaleDateString(getLocale(), { - month: 'long', - year: 'numeric', - day: 'numeric', - timeZone: getTimezone() - }) - handleInputChange(e, formattedDate) + const [localeDate, utcIsoDate] = parseDate(value) + if (localeDate) { + if (localeDate !== value) { + onChange?.(e, localeDate, utcIsoDate) + } + } else if (value && invalidDateErrorMessage) { + setInputMessages([{ type: 'error', text: invalidDateErrorMessage }]) } - onRequestValidateDate?.(value, isInputValid) - onBlur?.(e) + onRequestValidateDate?.(e, value || '', utcIsoDate) + onBlur?.(e, value || '', utcIsoDate) } + const selectedDate = parseDate(value)[1] return ( @@ -225,8 +314,8 @@ const DateInput2 = ({ onDateSelected={handleDateSelected} selectedDate={selectedDate} visibleMonth={selectedDate} - locale={locale} - timezone={timezone} + locale={getLocale()} + timezone={getTimezone()} role="listbox" renderNextMonthButton={ void + onChange?: ( + event: React.SyntheticEvent, + inputValue: 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 @@ -90,24 +97,6 @@ type DateInput2Props = { * }` */ 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. @@ -135,7 +124,7 @@ type DateInput2Props = { * This property can also be set via a context property and if both are set * then the component property takes precedence over the context property. * - * The web browser's timezone will be used if no value is set via a component + * The system timezone will be used if no value is set via a component * property or a context property. **/ timezone?: string @@ -157,15 +146,39 @@ type DateInput2Props = { startYear: number endYear: number } + /** + * 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 | null + formatter: (date: Date) => string + } + | string + + /** + * Callback executed when the input fires a blur event or a date is selected from the picker. + */ + onRequestValidateDate?: ( + event: React.SyntheticEvent, + value: string, + utcDateString: string + ) => void + // margin?: Spacing // TODO enable this prop } -type PropKeys = keyof DateInput2Props +type PropKeys = keyof DateInput2OwnProps + +type DateInput2Props = DateInput2OwnProps & + OtherHTMLAttributes< + DateInput2OwnProps, + InputHTMLAttributes + > 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, @@ -174,16 +187,15 @@ 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 ]), locale: PropTypes.string, timezone: PropTypes.string, - withYearPicker: PropTypes.object + withYearPicker: PropTypes.object, + dateFormat: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), + onRequestValidateDate: PropTypes.func } export type { DateInput2Props }