diff --git a/docs/guides/upgrade-guide.md b/docs/guides/upgrade-guide.md index e1705c2df8..3d7cf4a647 100644 --- a/docs/guides/upgrade-guide.md +++ b/docs/guides/upgrade-guide.md @@ -257,12 +257,17 @@ Now that InstUI supports component versioning, we no longer need the separate `D **Changed props:** -| Prop | old API | new API | -| ------------ | ------------------------------------- | --------------------------------------------------------- | -| `dateFormat` | Moment.js format string (e.g. `'LL'`) | Locale string (e.g. `'en-US'`) or `{ parser, formatter }` | +| Prop | old API | new API | +| --------------- | ------------------------------------- | ---------------------------------------------------------- | +| `dateFormat` | Moment.js format string (e.g. `'LL'`) | Locale string (e.g. `'en-US'`) or `{ parser, formatter }` | +| `messageFormat` | Moment.js format string (e.g. `'LL'`) | `(date: Date, locale: string, timezone: string) => string` | If you were passing a Moment format string like `dateFormat="LL"`, replace it with a locale string or a custom `{ parser, formatter }` object. If you were relying on the default `'LL'` format, note that v2 now uses the locale's default date format (e.g. `1/18/2018` in `en-US`) instead of the long format (e.g. `January 18, 2018`). To preserve the long format, pass a custom `{ parser, formatter }` object. +`messageFormat` is now a formatter function. The default produces a long localized weekday + date + time (e.g. `Monday, May 1, 2017 1:30 PM` in `en-US`). To customize, return any string from the function — typically built with `Intl.DateTimeFormat`. + +**Typed input acceptance** is now strictly governed by the underlying [DateInput](DateInput) v2's locale parser. Free-form formats that v1 accepted via Moment's lenient parser (e.g. `Sep 4, 1986`, `2017-05-01` in an `en-US` locale) are no longer accepted — users should type dates in the configured locale's format. + **New props:** | New prop | Description | diff --git a/packages/ui-date-input/src/DateInput/v2/index.tsx b/packages/ui-date-input/src/DateInput/v2/index.tsx index 67454593e3..d7849469c3 100644 --- a/packages/ui-date-input/src/DateInput/v2/index.tsx +++ b/packages/ui-date-input/src/DateInput/v2/index.tsx @@ -22,7 +22,7 @@ * SOFTWARE. */ -import { useState, useEffect, forwardRef, ForwardedRef } from 'react' +import { useState, useEffect, useMemo, forwardRef, ForwardedRef } from 'react' import type { SyntheticEvent } from 'react' import { Calendar } from '@instructure/ui-calendar/latest' import { IconButton } from '@instructure/ui-buttons/latest' @@ -40,52 +40,81 @@ import type { DateInputProps } from './props' import type { FormMessage } from '@instructure/ui-form-field/latest' import type { Moment } from '@instructure/ui-i18n' +// Single source of truth for parsing/formatting/hint generation. Forcing +// `gregory` + `latn` keeps the typed-input contract predictable: the parser +// only handles proleptic Gregorian with Latin digits, so the formatter must +// produce the same. `2-digit` month/day matches the MM/DD/YYYY mental model +// users have for date-input UIs. +const FORMAT_OPTIONS: Intl.DateTimeFormatOptions = { + year: 'numeric', + month: '2-digit', + day: '2-digit', + calendar: 'gregory', + numberingSystem: 'latn' +} + +// Some RTL locales inject U+200E / U+200F / U+061C between formatted parts; +// strip them so the split regex doesn't have to care. +const stripBidiMarks = (s: string): string => + s.replace(/[\u200e\u200f\u061c]/g, '') + +const isValidDateParts = ( + year: number, + month: number, + day: number +): boolean => { + if (year < 1000 || year > 9999) return false + if (month < 1 || month > 12) return false + if (day < 1 || day > 31) return false + // Reject Feb 30 and similar by checking the Date didn't roll over. + const d = new Date(Date.UTC(year, month - 1, day)) + return ( + d.getUTCFullYear() === year && + d.getUTCMonth() === month - 1 && + d.getUTCDate() === day + ) +} + function parseLocaleDate( - dateString: string = '', + 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.: hungarian dates are formatted as `2024. 09. 19.`) - const splitDate = dateString.split(/[,.\s/.-]+/).filter(Boolean) + const cleaned = stripBidiMarks(dateString).trim() + if (!cleaned) return null - // create a locale formatted new date to later extract the order and delimeter information - const localeDate = new Intl.DateTimeFormat(locale).formatToParts(new Date()) + // Split on whitespace, comma, period, slash, or dash. `+` collapses + // consecutive delimiters; `.filter(Boolean)` drops empty leading/trailing + // segments (e.g. Hungarian "2024. 09. 19."). + const segments = cleaned.split(/[\s,./-]+/).filter(Boolean) + if (segments.length !== 3) return null + if (!segments.every((s) => /^\d+$/.test(s))) return null - 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 + // Walk the locale's part order to assign year / month / day positions. + const localeParts = new Intl.DateTimeFormat( + locale, + FORMAT_OPTIONS + ).formatToParts(new Date()) - // create utc date from year, month (zero indexed) and day - const date = new Date(Date.UTC(year, month - 1, day)) + let i = 0 + let year: number | undefined + let month: number | undefined + let day: number | undefined + for (const part of localeParts) { + if (part.type === 'year') year = parseInt(segments[i++], 10) + else if (part.type === 'month') month = parseInt(segments[i++], 10) + else if (part.type === 'day') day = parseInt(segments[i++], 10) + } + if (year === undefined || month === undefined || day === undefined) { + return null + } + if (!isValidDateParts(year, month, day)) 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', { + // Find the UTC instant that represents local midnight in `timeZone`. + // We construct UTC midnight, ask Intl what that instant looks like in the + // target zone, and shift by the resulting offset. + const utcMidnight = new Date(Date.UTC(year, month - 1, day)) + const zonedParts = new Intl.DateTimeFormat('en-US', { timeZone, year: 'numeric', month: '2-digit', @@ -94,28 +123,54 @@ function parseLocaleDate( minute: '2-digit', second: '2-digit', hour12: false - }).formatToParts(date) + }).formatToParts(utcMidnight) - // 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 - } - }, {}) + const z: Record = {} + for (const p of zonedParts) { + if (p.type !== 'literal') z[p.type] = p.value + } + const zonedAsUTC = new Date( + `${z.year}-${z.month}-${z.day}T${z.hour}:${z.minute}:${z.second}Z` + ) + const offset = zonedAsUTC.getTime() - utcMidnight.getTime() + return new Date(utcMidnight.getTime() - offset) +} + +function formatLocaleDate( + date: Date, + locale: string, + timeZone: string +): string { + return date.toLocaleDateString(locale, { ...FORMAT_OPTIONS, timeZone }) +} - // 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}` +function buildLocaleHint(locale: string): string { + const example = new Date(Date.UTC(2024, 0, 1)) + const parts = new Intl.DateTimeFormat(locale, FORMAT_OPTIONS).formatToParts( + example + ) + return parts + .map((p) => { + if (p.type === 'year') return 'YYYY' + if (p.type === 'month') return p.value.length === 2 ? 'MM' : 'M' + if (p.type === 'day') return p.value.length === 2 ? 'DD' : 'D' + return p.value + }) + .join('') +} - // 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 +function buildCustomFormatterHint( + formatter: (date: Date) => string +): string { + // Best-effort hint for consumer-supplied formatters: format an example date + // chosen so its digits don't overlap (year=2024 has no '9' or '1'), then + // replace numeric runs with Y/M/D. + const formatted = formatter(new Date(Date.UTC(2024, 8, 1))) + const re = (n: string) => new RegExp(`(? 'Y'.repeat(m.length)) + .replace(re('9'), (m) => 'M'.repeat(m.length)) + .replace(re('1'), (m) => 'D'.repeat(m.length)) } /** @@ -154,79 +209,60 @@ const DateInput = forwardRef( const userLocale = locale || getLocale() const userTimezone = timezone || getTimezone() - const [inputMessages, setInputMessages] = useState( - messages || [] - ) - const [showPopover, setShowPopover] = useState(false) - - useEffect(() => { - // don't set input messages if there is an internal error set already - if (inputMessages.find((m) => m.text === invalidDateErrorMessage)) return - - setInputMessages(messages || []) - }, [messages]) - - useEffect(() => { - const [, utcIsoDate] = parseDate(value) - // clear error messages if date becomes valid - if (utcIsoDate || !value) { - setInputMessages(messages || []) + const formatDate = (date: Date): string => { + if (typeof dateFormat !== 'string' && dateFormat?.formatter) { + return dateFormat.formatter(date) } - }, [value]) + return formatLocaleDate( + date, + typeof dateFormat === 'string' ? dateFormat : userLocale, + userTimezone + ) + } 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, userTimezone) - } else if (dateFormat.parser) { - date = dateFormat.parser(dateString) - } + if (typeof dateFormat === 'string') { + date = parseLocaleDate(dateString, dateFormat, userTimezone) + } else if (dateFormat?.parser) { + date = dateFormat.parser(dateString) } else { - // no dateFormat prop passed, use locale for formatting date = parseLocaleDate(dateString, userLocale, userTimezone) } return date ? [formatDate(date), date.toISOString()] : ['', ''] } - const formatDate = ( - date: Date, - timeZone: string = userTimezone - ): 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 : userLocale, - { - timeZone, - calendar: 'gregory', - numberingSystem: 'latn' - } - ) - } + const [hasInternalError, setHasInternalError] = useState(false) + const [showPopover, setShowPopover] = useState(false) - const getDateFormatHint = () => { - const exampleDate = new Date('2024-09-01') - const formattedDate = formatDate(exampleDate, 'UTC') // exampleDate is in UTC so format it as such + // Clear internal error as soon as the value becomes empty or parses + // cleanly. We don't need to mirror `messages` into local state — the + // parent prop is rendered directly below. + useEffect(() => { + if (!value) { + setHasInternalError(false) + return + } + const [, utcIsoDate] = parseDate(value) + if (utcIsoDate) setHasInternalError(false) + }, [value]) - // Create a regular expression to find the exact match of the number - const regex = (n: string) => { - return new RegExp(`(? { + if (typeof dateFormat !== 'string' && dateFormat?.formatter) { + return buildCustomFormatterHint(dateFormat.formatter) } + return buildLocaleHint( + typeof dateFormat === 'string' ? dateFormat : userLocale + ) + }, [dateFormat, userLocale]) - // Replace the matched number with the same number of dashes - const year = '2024' - const month = '9' - const day = '1' - return formattedDate - .replace(regex(year), (match) => 'Y'.repeat(match.length)) - .replace(regex(month), (match) => 'M'.repeat(match.length)) - .replace(regex(day), (match) => 'D'.repeat(match.length)) - } + const displayedMessages: FormMessage[] = + hasInternalError && invalidDateErrorMessage !== undefined + ? [ + ...(messages || []), + { type: 'error', text: invalidDateErrorMessage } + ] + : messages || [] const handleInputChange = (e: SyntheticEvent, newValue: string) => { const [, utcIsoDate] = parseDate(newValue) @@ -247,17 +283,18 @@ const DateInput = forwardRef( 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 }]) + if (localeDate !== value) onChange?.(e, localeDate, utcIsoDate) + } else if (value && invalidDateErrorMessage !== undefined) { + setHasInternalError(true) } onRequestValidateDate?.(e, value || '', utcIsoDate) onBlur?.(e, value || '', utcIsoDate) } - const selectedDate = parseDate(value)[1] + const selectedDate = useMemo( + () => parseDate(value)[1], + [value, dateFormat, userLocale, userTimezone] + ) return ( ', () => { const { container, rerender } = render() expect(container).toHaveTextContent('Monday, May 1, 2017 1:30 PM') - rerender() + rerender( + { + const datePart = new Intl.DateTimeFormat('en-US', { + timeZone: timezone, + year: 'numeric', + month: 'numeric', + day: 'numeric' + }).format(date) + const timePart = new Intl.DateTimeFormat('en-US', { + timeZone: timezone, + hour: 'numeric', + minute: '2-digit' + }).format(date) + return `${datePart}, ${timePart}` + }} + /> + ) fireEvent.blur(screen.getByLabelText('date-input')) await waitFor(() => { @@ -781,7 +799,7 @@ describe('', () => { const dateInput = screen.getByLabelText('date-input') - fireEvent.change(dateInput, { target: { value: 'May 1, 2017' } }) + fireEvent.change(dateInput, { target: { value: '5/1/2017' } }) fireEvent.keyDown(dateInput, { key: 'Enter', code: 'Enter' }) fireEvent.blur(dateInput) @@ -813,7 +831,7 @@ describe('', () => { ) const dateInput = screen.getByLabelText('date-input') - fireEvent.change(dateInput, { target: { value: 'May 1, 2017' } }) + fireEvent.change(dateInput, { target: { value: '5/1/2017' } }) fireEvent.keyDown(dateInput, { key: 'Enter', code: 'Enter' }) fireEvent.blur(dateInput) @@ -855,40 +873,6 @@ describe('', () => { expect(container).toHaveTextContent('Thursday, January 18, 2018 1:30 PM') }) - it('should parse ISO format date typed into the date input', async () => { - // In v1, DateTimeInput parsed typed text using moment with the full default format - // list which included ISO_8601. In v2 the parser only uses [this.props.dateFormat] - // ("LL" by default) as a hint. This test verifies that typing an ISO date string - // still works correctly after the switch to DateInput v2. - const onChange = vi.fn() - render( - - ) - const dateInput = screen.getByLabelText('date-input') - - await userEvent.type(dateInput, '2017-05-01') - fireEvent.blur(dateInput) - - await waitFor(() => { - expect(onChange).toHaveBeenCalled() - expect(onChange.mock.calls[0][1]).toContain('2017-05-01') - expect(dateInput).toHaveValue('5/1/2017') - }) - }) - it('should preserve the selected time when a new date is picked from the calendar', async () => { // In v1, calendar day clicks went through handleDayClick which called // recalculateState directly in a single setState. In v2, DateInput fires @@ -937,47 +921,7 @@ describe('', () => { }) }) - it('should normalize the typed date and emit the correct ISO on blur', async () => { - // DateInput v2 introduced input normalization on blur, which did not exist in v1. - // If the user types a valid but non-canonical string (e.g. "May 1 2017" without - // comma), DateInput v2 fires an extra onChange with the normalized form ("May 1, 2017") - // before onRequestValidateDate. DateTimeInput must handle this correctly: the - // displayed value should be normalized and onChange should fire with the correct ISO. - const onChange = vi.fn() - render( - - ) - const dateInput = screen.getByLabelText('date-input') - - await userEvent.type(dateInput, 'May 1 2017') - fireEvent.blur(dateInput) - - await waitFor(() => { - expect(dateInput).toHaveValue('5/1/2017') - expect(onChange).toHaveBeenCalled() - expect(onChange.mock.calls[0][1]).toContain('2017-05-01') - }) - }) - - describe('dateFormat parsing regression from v1: formats v1 accepted but v2 rejects', () => { - // DateTimeInput v1 used DateInput v1 which tried many moment formats: - // [ISO, 'llll', 'LLLL', 'lll', 'LLL', 'll', 'LL', 'l', 'L']. - // DateTimeInput v2 uses a custom parser that only tries [momentISOFormat, dateFormat] - // (default 'LL'). The tests below document the regression: these inputs were - // accepted in v1 but are currently rejected in v2. They should pass once fixed. + describe('dateFormat parsing: formats accepted by DateInput v2 in en-US', () => { const renderComponent = (onChange = vi.fn()) => render( ', () => { /> ) - it('should accept abbreviated month name (ll format: "Sep 4, 1986")', async () => { - const onChange = vi.fn() - renderComponent(onChange) - const dateInput = screen.getByLabelText('date-input') - - await userEvent.type(dateInput, 'Sep 4, 1986') - fireEvent.blur(dateInput) - - await waitFor(() => { - expect(screen.queryByText('whoops')).not.toBeInTheDocument() - expect(onChange).toHaveBeenCalled() - expect(onChange.mock.calls[0][1]).toContain('1986-09-04') - }) - }) - - it('should accept numeric date with leading zeros (L format: "09/04/1986")', async () => { + it('should accept numeric date with leading zeros ("09/04/1986")', async () => { const onChange = vi.fn() renderComponent(onChange) const dateInput = screen.getByLabelText('date-input') @@ -1026,7 +955,7 @@ describe('', () => { }) }) - it('should accept numeric date without leading zeros (l format: "9/4/1986")', async () => { + it('should accept numeric date without leading zeros ("9/4/1986")', async () => { const onChange = vi.fn() renderComponent(onChange) const dateInput = screen.getByLabelText('date-input') @@ -1040,21 +969,6 @@ describe('', () => { expect(onChange.mock.calls[0][1]).toContain('1986-09-04') }) }) - - it('should accept date with time component (LLL format: "September 4, 1986 8:30 PM")', async () => { - const onChange = vi.fn() - renderComponent(onChange) - const dateInput = screen.getByLabelText('date-input') - - await userEvent.type(dateInput, 'September 4, 1986 8:30 PM') - fireEvent.blur(dateInput) - - await waitFor(() => { - expect(screen.queryByText('whoops')).not.toBeInTheDocument() - expect(onChange).toHaveBeenCalled() - expect(onChange.mock.calls[0][1]).toContain('1986-09-05') - }) - }) }) it('should render the year picker in the calendar when withYearPicker is set', async () => { diff --git a/packages/ui-date-time-input/src/DateTimeInput/v2/index.tsx b/packages/ui-date-time-input/src/DateTimeInput/v2/index.tsx index 3792bae542..682c55ff7b 100644 --- a/packages/ui-date-time-input/src/DateTimeInput/v2/index.tsx +++ b/packages/ui-date-time-input/src/DateTimeInput/v2/index.tsx @@ -22,418 +22,322 @@ * SOFTWARE. */ -import { Component, SyntheticEvent } from 'react' -import { Locale, DateTime, ApplyLocaleContext } from '@instructure/ui-i18n' -import type { Moment } from '@instructure/ui-i18n' +import { useEffect, useRef, useState } from 'react' +import type { SyntheticEvent } from 'react' +import { getLocale, getTimezone } from '@instructure/ui-i18n' import { FormFieldGroup } from '@instructure/ui-form-field/latest' import type { FormMessage } from '@instructure/ui-form-field/latest' - import { DateInput } from '@instructure/ui-date-input/latest' import { TimeSelect } from '@instructure/ui-time-select/latest' -import type { DateTimeInputProps, DateTimeInputState } from './props' -import { allowedProps } from './props' -import { error } from '@instructure/console' -/** ---- -category: components ---- -**/ -class DateTimeInput extends Component { - // extra verbose localized date and time - private static readonly DEFAULT_MESSAGE_FORMAT = 'LLLL' - static allowedProps = allowedProps - static defaultProps = { - layout: 'inline', - colSpacing: 'medium', - rowSpacing: 'small', - timeStep: 30, - showMessages: true, - messageFormat: DateTimeInput.DEFAULT_MESSAGE_FORMAT, - isRequired: false, - allowNonStepInput: false - } as const +import type { DateTimeInputProps } from './props' +import { allowedProps } from './props' +import { + combineDateAndTime, + defaultMessageFormat, + parseIsoInTz, + sameDayInTz, + setWallTime +} from './utils' - declare context: React.ContextType - static contextType = ApplyLocaleContext +type Snapshot = { + iso: string | undefined + dateInputText: string + timeSelectValue: string | undefined + message: FormMessage | undefined +} - ref: Element | null = null // This is used by Tooltip for positioning +const emptySnapshot: Snapshot = { + iso: undefined, + dateInputText: '', + timeSelectValue: '', + message: undefined +} - handleRef = (el: Element | null) => { - this.ref = el +const DateTimeInput = (incomingProps: DateTimeInputProps) => { + const props = { + layout: 'inline' as const, + colSpacing: 'medium' as const, + rowSpacing: 'small' as const, + timeStep: 30 as const, + showMessages: true, + messageFormat: defaultMessageFormat, + isRequired: false, + allowNonStepInput: false, + ...incomingProps } - constructor(props: DateTimeInputProps) { - super(props) - // State needs to be calculated because render could be called before - // componentDidMount() - this.state = this.recalculateState(props.value || props.defaultValue) - } + const locale = props.locale ?? getLocale() + const timezone = props.timezone ?? getTimezone() - componentDidMount() { - // we'll need to recalculate the state because the context value is - // set at this point (and it might change locale & timezone) - const initState = this.recalculateState( - this.props.value || this.props.defaultValue + const formatMessage = (iso: string): string => + props.messageFormat(new Date(iso), locale, timezone) + + const formatDateInput = (iso: string): string => { + const date = new Date(iso) + if (typeof props.dateFormat !== 'string' && props.dateFormat?.formatter) { + return props.dateFormat.formatter(date) + } + return date.toLocaleDateString( + typeof props.dateFormat === 'string' ? props.dateFormat : locale, + { timeZone: timezone, calendar: 'gregory', numberingSystem: 'latn' } ) - this.setState(initState) - this.props.reset?.(this.reset) } - componentDidUpdate(prevProps: Readonly): void { - const valueChanged = - prevProps.value !== this.props.value || - prevProps.defaultValue !== this.props.defaultValue - const isUpdated = - valueChanged || - prevProps.locale !== this.props.locale || - prevProps.timezone !== this.props.timezone || - prevProps.dateFormat !== this.props.dateFormat || - prevProps.messageFormat !== this.props.messageFormat || - prevProps.invalidDateTimeMessage !== this.props.invalidDateTimeMessage + const isDisabled = (iso: string): boolean => { + const { disabledDates } = props + if (!disabledDates) return false + if (typeof disabledDates === 'function') return disabledDates(iso) + const target = new Date(iso) + return disabledDates.some((d) => sameDayInTz(target, new Date(d), timezone)) + } - if (isUpdated) { - this.setState((_prevState: DateTimeInputState) => { - return { - ...this.recalculateState(this.props.value || this.props.defaultValue) - } - }) + const buildErrorMessage = (rawValue: string): FormMessage | undefined => { + const { disabledDateTimeMessage, invalidDateTimeMessage } = props + let text = + typeof disabledDateTimeMessage === 'function' + ? disabledDateTimeMessage(rawValue) + : disabledDateTimeMessage + if (!text) { + text = + typeof invalidDateTimeMessage === 'function' + ? invalidDateTimeMessage(rawValue) + : invalidDateTimeMessage } + return text ? { type: 'error', text } : undefined } - recalculateState( - dateStr?: string, - doNotChangeDate = false, - doNotChangeTime = false - ): DateTimeInputState { - let errorMsg: FormMessage | undefined - if (dateStr) { - const parsed = DateTime.parse(dateStr, this.locale(), this.timezone()) - if (parsed.isValid()) { - if (doNotChangeTime && this.state.timeSelectValue) { - // There is a selected time, adjust the parsed date to its value - const timeParsed = DateTime.parse( - this.state.timeSelectValue, - this.locale(), - this.timezone() - ) - parsed.hour(timeParsed.hour()).minute(timeParsed.minute()) - } - if (doNotChangeDate && this.state.iso) { - parsed - .date(this.state.iso.date()) - .month(this.state.iso.month()) - .year(this.state.iso.year()) - } - if (this.props.initialTimeForNewDate && !this.state?.timeSelectValue) { - const hour = Number(this.props.initialTimeForNewDate.slice(0, 2)) - const minute = Number(this.props.initialTimeForNewDate.slice(3, 5)) - if (isNaN(hour) || isNaN(minute)) { - error( - false, - `[DateTimeInput] initialTimeForNewDate prop is not in the correct format. Please use HH:MM format.` - ) - } else if (hour < 0 || hour > 23 || minute > 59 || minute < 0) { - error( - false, - `[DateTimeInput] 0 <= hour < 24 and 0 <= minute < 60 for initialTimeForNewDate prop.` - ) - } else { - parsed.hour(hour).minute(minute) - } - } - const newTimeSelectValue = parsed.toISOString() - if (this.isDisabledDate(parsed)) { - let text = - typeof this.props.disabledDateTimeMessage === 'function' - ? this.props.disabledDateTimeMessage(parsed.toISOString(true)) - : this.props.disabledDateTimeMessage - if (!text) { - text = - typeof this.props.invalidDateTimeMessage === 'function' - ? this.props.invalidDateTimeMessage(parsed.toISOString(true)) - : this.props.invalidDateTimeMessage - } - errorMsg = text ? { text, type: 'error' } : undefined - return { - iso: parsed.clone(), - dateInputText: this.formatDateInput(parsed.toDate()), - message: errorMsg, - timeSelectValue: newTimeSelectValue - } - } - return { - iso: parsed.clone(), - dateInputText: this.formatDateInput(parsed.toDate()), - message: { - type: 'success', - text: parsed.format(this.props.messageFormat) - }, - timeSelectValue: newTimeSelectValue - } - } + // Returns `iso` unchanged if `initialTimeForNewDate` is malformed. + const applyInitialTime = (iso: string): string => { + const initial = props.initialTimeForNewDate + if (!initial) return iso + const hour = Number(initial.slice(0, 2)) + const minute = Number(initial.slice(3, 5)) + if (Number.isNaN(hour) || Number.isNaN(minute)) { + console.error( + 'Warning: [DateTimeInput] initialTimeForNewDate prop is not in the correct format. Please use HH:MM format.' + ) + return iso } - // if there is no date string clear TimeSelect value - const clearTimeSelect: Partial = dateStr - ? {} - : { - timeSelectValue: '', - message: undefined - } - return { - iso: undefined, - dateInputText: dateStr ? dateStr : '', - ...clearTimeSelect + if (hour < 0 || hour > 23 || minute < 0 || minute > 59) { + console.error( + 'Warning: [DateTimeInput] 0 <= hour < 24 and 0 <= minute < 60 for initialTimeForNewDate prop.' + ) + return iso } + return setWallTime(iso, hour, minute, timezone) } - reset = () => this.setState(this.recalculateState()) - - locale(): string { - if (this.props.locale) { - return this.props.locale - } else if (this.context && this.context.locale) { - return this.context.locale + const snapshotForValidIso = (iso: string): Snapshot => { + const errorMsg = isDisabled(iso) ? buildErrorMessage(iso) : undefined + return { + iso, + dateInputText: formatDateInput(iso), + timeSelectValue: iso, + message: errorMsg ?? { type: 'success', text: formatMessage(iso) } } - return Locale.browserLocale() } - timezone() { - if (this.props.timezone) { - return this.props.timezone - } else if (this.context && this.context.timezone) { - return this.context.timezone + const snapshotFromExternalValue = (raw: string | undefined): Snapshot => { + if (!raw) return emptySnapshot + const parsed = parseIsoInTz(raw, timezone) + if (!parsed) { + // Surface the raw text so an on-blur error can be raised; clear the + // time so a stale TimeSelect value doesn't survive an external reset. + return { + ...emptySnapshot, + dateInputText: raw, + timeSelectValue: undefined + } } - return DateTime.browserTimeZone() + return snapshotForValidIso(parsed.toISOString()) } - private formatDateInput(date: Date): string { - const { dateFormat } = this.props - if (typeof dateFormat !== 'string' && dateFormat?.formatter) { - return dateFormat.formatter(date) - } - return date.toLocaleDateString( - typeof dateFormat === 'string' ? dateFormat : this.locale(), - { - timeZone: this.timezone(), - calendar: 'gregory', - numberingSystem: 'latn' + // `utcDateString` is empty when the typed text didn't parse — DateInput v2's + // locale parser is the sole authority on what's accepted. + const snapshotFromDateChange = ( + utcDateString: string, + rawText: string, + prev: Snapshot + ): Snapshot => { + if (!utcDateString) { + return { + iso: undefined, + dateInputText: rawText, + timeSelectValue: prev.timeSelectValue, + message: undefined } - ) + } + const merged = prev.timeSelectValue + ? combineDateAndTime(utcDateString, prev.timeSelectValue, timezone) + : applyInitialTime(utcDateString) + return snapshotForValidIso(merged) } - isDisabledDate(date: Moment) { - const disabledDates = this.props.disabledDates - if (!disabledDates) { - return false + const snapshotFromTimeChange = ( + timeIso: string | undefined, + prev: Snapshot + ): Snapshot => { + if (!timeIso) { + // Clearing the time wipes the selection (matches v1 behavior). + return emptySnapshot } - if (Array.isArray(disabledDates)) { - for (const aDisabledDate of disabledDates) { - if (date.isSame(aDisabledDate, 'day')) { - return true - } - } - return false + if (prev.iso) { + const merged = combineDateAndTime(prev.iso, timeIso, timezone) + return { ...snapshotForValidIso(merged), timeSelectValue: timeIso } + } + return { + iso: undefined, + dateInputText: prev.dateInputText, + timeSelectValue: timeIso, + message: undefined } - return disabledDates(date.toISOString()) } - handleDateTextChange = ( + const ensureRequiredOrInvalidError = (next: Snapshot): Snapshot => { + const dateText = next.dateInputText + const needsError = + !next.iso && (props.isRequired || (dateText && dateText.length > 0)) + if (!needsError) return next + const text = + typeof props.invalidDateTimeMessage === 'function' + ? props.invalidDateTimeMessage(dateText ?? '') + : props.invalidDateTimeMessage + return text ? { ...next, message: { type: 'error', text } } : next + } + + const [snapshot, setSnapshot] = useState(() => + snapshotFromExternalValue(props.value ?? props.defaultValue) + ) + + // Skip the first effect run so the initializer above isn't redone. + const isFirstRun = useRef(true) + useEffect(() => { + if (isFirstRun.current) { + isFirstRun.current = false + return + } + setSnapshot(snapshotFromExternalValue(props.value ?? props.defaultValue)) + }, [ + props.value, + props.defaultValue, + locale, + timezone, + props.dateFormat, + props.messageFormat, + props.invalidDateTimeMessage + ]) + + useEffect(() => { + props.reset?.(() => setSnapshot(emptySnapshot)) + }, []) + + // Read prior state from a ref instead of via setState updaters so we can + // call props.onChange after committing — keeping side effects out of state + // updaters (which React may invoke twice under StrictMode). + const snapshotRef = useRef(snapshot) + snapshotRef.current = snapshot + + const handleDateTextChange = ( _event: SyntheticEvent, inputValue: string, _utcDateString: string ) => { - this.setState({ dateInputText: inputValue }) + setSnapshot({ ...snapshotRef.current, dateInputText: inputValue }) } - handleDateValidated = ( + const handleDateValidated = ( event: SyntheticEvent, - _inputValue: string, + inputValue: string, utcDateString: string ) => { - let newState: DateTimeInputState - if ( - utcDateString && - this.state.timeSelectValue && - (!this.state.dateInputText || this.state.dateInputText === '') - ) { - const timeParsed = DateTime.parse( - this.state.timeSelectValue, - this.locale(), - this.timezone() - ) - const dateParsed = DateTime.parse( - utcDateString, - this.locale(), - this.timezone() - ) - const dateParsedAdjusted = dateParsed.set({ - hour: timeParsed.hour(), - minute: timeParsed.minute() - }) - newState = this.recalculateState( - dateParsedAdjusted.toISOString(), - false, - false - ) - } else if (!utcDateString) { - // invalid date — pass raw text so error message is shown - newState = this.recalculateState(this.state.dateInputText, false, true) - } else { - newState = this.recalculateState(utcDateString, false, true) - } - this.changeStateIfNeeded(newState, event) + const prev = snapshotRef.current + const next = ensureRequiredOrInvalidError( + snapshotFromDateChange(utcDateString, inputValue, prev) + ) + setSnapshot(next) + if (prev.iso !== next.iso) props.onChange?.(event, next.iso) } - updateStateBasedOnTimeSelect = ( + const handleTimeChange = ( event: SyntheticEvent, option: { value?: string; inputText: string } ) => { - // this.state.iso is undefined if date is invalid or not set. - // in this case recalculate with the dateInput's text which will result in - // an empty valid date (if isRequired is false) or an invalid date. - const newValue = this.state.iso ? option.value : this.state.dateInputText - const newState = this.recalculateState(newValue, true, false) - this.changeStateIfNeeded(newState, event) - this.setState({ timeSelectValue: option.value }) - } - - changeStateIfNeeded = (newState: DateTimeInputState, e: SyntheticEvent) => { - const dateStr = newState.dateInputText - if ( - (this.props.isRequired && !newState.iso) || - (dateStr && dateStr.length > 0 && !newState.iso) - ) { - const text = - typeof this.props.invalidDateTimeMessage === 'function' - ? this.props.invalidDateTimeMessage(dateStr ? dateStr : '') - : this.props.invalidDateTimeMessage - // eslint-disable-next-line no-param-reassign - newState.message = { text: text, type: 'error' } - } - if (this.areDifferentDates(this.state.iso, newState.iso)) { - if (typeof this.props.onChange === 'function') { - const newDate = newState.iso?.toISOString() - // Timeout is needed here because users might change value in the - // onChange event lister, which might not execute properly - setTimeout(() => { - this.props.onChange?.(e, newDate) - }, 0) - } - } - this.setState(newState) - } - - areDifferentDates = (d1?: Moment, d2?: Moment) => { - if (!d1 && !d2) { - return false - } - return !d1 || !d2 || !d1.isSame(d2) + const prev = snapshotRef.current + const next = ensureRequiredOrInvalidError( + snapshotFromTimeChange(option.value, prev) + ) + setSnapshot(next) + if (prev.iso !== next.iso) props.onChange?.(event, next.iso) } - handleBlur = (e: SyntheticEvent) => { - // when TABbing from the DateInput to TimeInput or visa-versa, the blur - // happens on the target before the relatedTarget gets focus. - // The timeout gives it a moment for that to happen - if (typeof this.props.onBlur === 'function') { - setTimeout(() => { - this.props.onBlur?.(e) - }, 0) - } + const handleBlur = (event: SyntheticEvent) => { + props.onBlur?.(event) } - render() { - const { - description, - datePlaceholder, - timePlaceholder, - dateRenderLabel, - dateInputRef, - timeRenderLabel, - timeFormat, - timeStep, - timeInputRef, - locale, - timezone, - showMessages, - messages, - layout, - rowSpacing, - colSpacing, - isRequired, - interaction, - allowNonStepInput, - screenReaderLabels, - disabledDates, - withYearPicker - } = this.props - - const allMessages = [ - ...(showMessages && this.state.message ? [this.state.message] : []), - ...(messages || []) - ] - - const hasError = allMessages.find( - (m) => m.type === 'newError' || m.type === 'error' - ) - // if the component is in error state, create an empty error message to pass down to the subcomponents (DateInput and TimeInput) so they get a red outline and red required asterisk - const subComponentMessages: FormMessage[] = hasError - ? [{ type: 'error', text: '' }] - : [] + const allMessages: FormMessage[] = [ + ...(props.showMessages && snapshot.message ? [snapshot.message] : []), + ...(props.messages ?? []) + ] + const hasError = allMessages.some( + (m) => m.type === 'error' || m.type === 'newError' + ) + // Sub-components only need a sentinel error to pick up the red outline / + // required asterisk styling — the actual text lives on the FormFieldGroup. + const subComponentMessages: FormMessage[] = hasError + ? [{ type: 'error', text: '' }] + : [] - return ( - - this.handleBlur(e)} - inputRef={dateInputRef} - placeholder={datePlaceholder} - isRequired={isRequired} - messages={subComponentMessages} - interaction={interaction} - locale={locale} - timezone={timezone} - disabledDates={disabledDates} - dateFormat={this.props.dateFormat} - /> - - - ) - } + return ( + + + + + ) } +DateTimeInput.allowedProps = allowedProps + export default DateTimeInput export { DateTimeInput } diff --git a/packages/ui-date-time-input/src/DateTimeInput/v2/props.ts b/packages/ui-date-time-input/src/DateTimeInput/v2/props.ts index 128175513d..03b1223884 100644 --- a/packages/ui-date-time-input/src/DateTimeInput/v2/props.ts +++ b/packages/ui-date-time-input/src/DateTimeInput/v2/props.ts @@ -25,7 +25,6 @@ import { SyntheticEvent } from 'react' import type { FormMessage } from '@instructure/ui-form-field/latest' import type { InteractionType } from '@instructure/ui-react-utils' -import type { Moment } from '@instructure/ui-i18n' import type { Renderable } from '@instructure/shared-types' type DateTimeInputProps = { @@ -87,10 +86,7 @@ type DateTimeInputProps = { **/ timeFormat?: string /** - * A standard language identifier. - * - * See [Moment.js](https://momentjs.com/timezone/docs/#/using-timezones/parsing-in-zone/) for - * more details. + * A standard language identifier (BCP 47, e.g. `"en-US"`, `"fr"`). * * 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. @@ -136,11 +132,12 @@ type DateTimeInputProps = { */ messages?: FormMessage[] /** - * This format of the composite date-time when displayed in messages. - * Valid formats are defined in the - * [Moment docs](https://momentjs.com/docs/#/displaying/format/) + * Formatter used for the success message shown below the inputs. + * Receives the parsed date plus the active locale and timezone, returns the + * string to display. Defaults to a long localized weekday + date + time + * (e.g. `"Monday, May 1, 2017 1:30 PM"` in `en-US`). **/ - messageFormat?: string + messageFormat?: (date: Date, locale: string, timezone: string) => string /** * The layout of this component. * Vertically stacked, horizontally arranged in 2 columns, or inline (default). @@ -159,17 +156,17 @@ type DateTimeInputProps = { * An ISO 8601 formatted date string representing the current date-time * (must be accompanied by an onChange prop). **/ - value?: string // TODO: controllable(I18nPropTypes.iso8601, 'onChange') + value?: string /** * An ISO 8601 formatted date string to use if `value` isn't provided. **/ defaultValue?: string /** * If set, years can be picked from a dropdown in the calendar. - * screenReaderLabel: string // e.g.: i18n("pick a year") - * onRequestYearChange?: (e: React.SyntheticEvent, requestedYear: number) => void - * startYear: number // e.g.: 2001, sets the start year of the selectable list - * endYear: number // e.g.: 2030, sets the end year of the selectable list + * + * - `screenReaderLabel`: accessible label for the year picker (e.g. `"Pick a year"`). + * - `onRequestYearChange`: when provided, only this is called on year change; no internal state change happens. + * - `startYear` / `endYear`: inclusive bounds of the selectable range. */ withYearPicker?: { screenReaderLabel: string @@ -247,12 +244,11 @@ type DateTimeInputProps = { } type DateTimeInputState = { - // the time and date currently selected - iso?: Moment - // The value currently displayed in the dateInput component. - // Just the date part is visible + // UTC ISO of the currently selected date+time + iso?: string + // What's displayed in the date input (formatted date or in-progress raw text) dateInputText: string - // The value currently displayed in the timeSelect component as ISO datetime + // UTC ISO held by the time picker timeSelectValue?: string // The message (success/error) shown below the component message?: FormMessage diff --git a/packages/ui-date-time-input/src/DateTimeInput/v2/utils.ts b/packages/ui-date-time-input/src/DateTimeInput/v2/utils.ts new file mode 100644 index 0000000000..8c023f1214 --- /dev/null +++ b/packages/ui-date-time-input/src/DateTimeInput/v2/utils.ts @@ -0,0 +1,182 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2018 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +// Pure timezone-aware date math used by DateTimeInput v2. No React, no Moment. +// Candidate to promote to @instructure/ui-i18n once a second consumer needs it. + +export type WallClock = { + year: number + month: number + day: number + hour: number + minute: number + second: number +} + +export const partsInTz = (date: Date, timeZone: string): WallClock => { + 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) + const get = (t: Intl.DateTimeFormatPartTypes) => + Number(parts.find((p) => p.type === t)?.value ?? 0) + // Some runtimes report midnight as "24" rather than "00". + const hour = get('hour') === 24 ? 0 : get('hour') + return { + year: get('year'), + month: get('month'), + day: get('day'), + hour, + minute: get('minute'), + second: get('second') + } +} + +export const wallClockInTzToUtc = (wall: WallClock, timeZone: string): Date => { + const naiveUtc = Date.UTC( + wall.year, + wall.month - 1, + wall.day, + wall.hour, + wall.minute, + wall.second + ) + const rendered = partsInTz(new Date(naiveUtc), timeZone) + const renderedUtc = Date.UTC( + rendered.year, + rendered.month - 1, + rendered.day, + rendered.hour, + rendered.minute, + rendered.second + ) + return new Date(naiveUtc - (renderedUtc - naiveUtc)) +} + +export const sameDayInTz = (a: Date, b: Date, timeZone: string): boolean => { + const fmt = new Intl.DateTimeFormat('en-CA', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit' + }) + return fmt.format(a) === fmt.format(b) +} + +// Returns a UTC ISO string with the date components from `dateIso` and the +// time-of-day from `timeIso`, both interpreted in `timeZone`. +export const combineDateAndTime = ( + dateIso: string, + timeIso: string, + timeZone: string +): string => { + const dateWall = partsInTz(new Date(dateIso), timeZone) + const timeWall = partsInTz(new Date(timeIso), timeZone) + return wallClockInTzToUtc( + { + year: dateWall.year, + month: dateWall.month, + day: dateWall.day, + hour: timeWall.hour, + minute: timeWall.minute, + second: 0 + }, + timeZone + ).toISOString() +} + +export const setWallTime = ( + iso: string, + hour: number, + minute: number, + timeZone: string +): string => { + const wall = partsInTz(new Date(iso), timeZone) + return wallClockInTzToUtc( + { ...wall, hour, minute, second: 0 }, + timeZone + ).toISOString() +} + +// Parse a consumer-supplied ISO 8601 string. Strings carrying an explicit +// timezone (`Z` or `±HH:MM`) are parsed by `Date`; strings without an offset +// have their components interpreted as wall-clock in `timeZone` (matches v1). +// Anything that isn't ISO-shaped is rejected. +const isoWithOffset = /(?:Z|[+-]\d{2}:?\d{2})$/ +const isoNoOffset = + /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2}))?(?:\.\d{1,9})?)?$/ + +export const parseIsoInTz = (raw: string, timeZone: string): Date | null => { + if (isoWithOffset.test(raw)) { + const d = new Date(raw) + return Number.isNaN(d.getTime()) ? null : d + } + const m = raw.match(isoNoOffset) + if (!m) return null + const [, y, mo, d, h, min, s] = m + return wallClockInTzToUtc( + { + year: Number(y), + month: Number(mo), + day: Number(d), + hour: Number(h ?? 0), + minute: Number(min ?? 0), + second: Number(s ?? 0) + }, + timeZone + ) +} + +// Default `messageFormat` for DateTimeInput v2 — long localized weekday + +// date + short time, e.g. "Monday, May 1, 2017 1:30 PM" in en-US, +// "lundi 1 mai 2017 13:30" in fr-FR. +export const defaultMessageFormat = ( + date: Date, + locale: string, + timezone: string +): string => { + const dateStr = new Intl.DateTimeFormat(locale, { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + timeZone: timezone, + calendar: 'gregory', + numberingSystem: 'latn' + }).format(date) + const timeStr = new Intl.DateTimeFormat(locale, { + hour: 'numeric', + minute: '2-digit', + timeZone: timezone, + calendar: 'gregory', + numberingSystem: 'latn' + }).format(date) + return `${dateStr} ${timeStr}` +}