diff --git a/packages/datetime2/src/components/date-input3/dateInput3.tsx b/packages/datetime2/src/components/date-input3/dateInput3.tsx index e918e2dcf3..b1303caa07 100644 --- a/packages/datetime2/src/components/date-input3/dateInput3.tsx +++ b/packages/datetime2/src/components/date-input3/dateInput3.tsx @@ -59,6 +59,7 @@ const defaultProps: DateInput3DefaultProps = { closeOnSelection: true, disabled: false, invalidDateMessage: "Invalid date", + locale: "en-US", maxDate: DatePickerUtils.getDefaultMaxDate(), minDate: DatePickerUtils.getDefaultMinDate(), outOfRangeMessage: "Out of range", @@ -278,6 +279,7 @@ export const DateInput3: React.FC = React.memo(function _DateIn | React.ChangeEvent; -export interface DateRangeInput3State { - isOpen?: boolean; - boundaryToModify?: Boundary; - lastFocusedField?: Boundary; - - formattedMinDateString?: string; - formattedMaxDateString?: string; - - isStartInputFocused: boolean; - isEndInputFocused: boolean; - - startInputString?: string; - endInputString?: string; - - startHoverString?: string | null; - endHoverString?: string | null; - - selectedEnd: Date | null; - selectedStart: Date | null; - - shouldSelectAfterUpdate?: boolean; - wasLastFocusChangeDueToHover?: boolean; - - selectedShortcutIndex?: number; -} - interface StateKeysAndValuesObject { keys: { hoverString: "startHoverString" | "endHoverString"; @@ -107,7 +83,7 @@ interface StateKeysAndValuesObject { * * @see https://blueprintjs.com/docs/#datetime2/date-range-input3 */ -export class DateRangeInput3 extends AbstractPureComponent { +export class DateRangeInput3 extends DateFnsLocalizedComponent { public static defaultProps: DateRangeInput3DefaultProps = { allowSingleDayRange: false, closeOnSelection: true, @@ -116,6 +92,7 @@ export class DateRangeInput3 extends AbstractPureComponent ); @@ -249,25 +233,6 @@ export class DateRangeInput3 extends AbstractPureComponent( - nextStateOrAction: - | Partial - | null - | (( - prevState: DateRangeInput3State, - prevProps: DateRangeInput3Props, - ) => Pick | null), - callback?: () => void, - ) { - if (typeof nextStateOrAction === "function") { - super.setState(nextStateOrAction, callback); - } else { - super.setState(nextStateOrAction as DateRangeInput3State); - } - } - protected validateProps(props: DateRangeInput3Props) { if (props.value === null) { // throw a blocking error here because we don't handle a null value gracefully across this component @@ -593,6 +558,7 @@ export class DateRangeInput3 extends AbstractPureComponent { const date = props[propName]; - const defaultDate = DateRangeInput3.defaultProps[propName]; - // default values are applied only if a prop is strictly `undefined` + + // N.B. default values are applied only if a prop is strictly `undefined` // See: https://facebook.github.io/react/docs/react-component.html#defaultprops - return formatDateString(date ?? defaultDate, this.props); - } + const defaultDate = DateRangeInput3.defaultProps[propName]; + + // N.B. this.state will be undefined in the constructor, so we need a fallback in that case + const maybeLocale = this.state?.locale ?? typeof props.locale === "string" ? undefined : props.locale; + + return formatDateString(date ?? defaultDate, this.props, maybeLocale); + }; - private parseDate(dateString: string | undefined): Date | null { + private parseDate = (dateString: string | undefined): Date | null => { if ( dateString === undefined || dateString === this.props.outOfRangeMessage || @@ -932,28 +903,72 @@ export class DateRangeInput3 extends AbstractPureComponent { if (!this.isDateValidAndInRange(date)) { return ""; } - return this.props.formatDate(date, getLocaleCodeFromProps(this.props.locale)); - } + + // HACKHACK: the code below is largely copied from the `useDateFormatter()` hook, which is the preferred + // implementation that we can migrate to once DateRangeInput3 is a function component. + const { dateFnsFormat, formatDate, locale: localeFromProps, timePickerProps, timePrecision } = this.props; + const { locale } = this.state; + + if (formatDate !== undefined) { + // user-provided date formatter + return formatDate(date, locale?.code ?? getLocaleCodeFromProps(localeFromProps)); + } else { + // use user-provided date-fns format or one of the default formats inferred from time picker props + const format = dateFnsFormat ?? getDefaultDateFnsFormat({ timePickerProps, timePrecision }); + return getDateFnsFormatter(format, locale)(date); + } + }; } -function formatDateString(date: Date | false | null | undefined, props: DateRangeInput3Props, ignoreRange = false) { - const { formatDate, invalidDateMessage, maxDate, minDate, outOfRangeMessage } = - props as DateRangeInput3PropsWithDefaults; +// called on initial construction, input focus & blur, and the standard input render path +function formatDateString( + date: Date | false | null | undefined, + props: DateRangeInput3Props, + locale: Locale | undefined, + ignoreRange = false, +) { + const { invalidDateMessage, maxDate, minDate, outOfRangeMessage } = props as DateRangeInput3PropsWithDefaults; if (date == null) { return ""; } else if (!DateUtils.isDateValid(date)) { return invalidDateMessage; } else if (ignoreRange || DateUtils.isDayInRange(date, [minDate, maxDate])) { - return formatDate(date, getLocaleCodeFromProps(props.locale)); + // HACKHACK: the code below is largely copied from the `useDateFormatter()` hook, which is the preferred + // implementation that we can migrate to once DateRangeInput3 is a function component. + const { dateFnsFormat, formatDate, locale: localeFromProps, timePickerProps, timePrecision } = props; + if (formatDate !== undefined) { + // user-provided date formatter + return formatDate(date, locale?.code ?? getLocaleCodeFromProps(localeFromProps)); + } else { + // use user-provided date-fns format or one of the default formats inferred from time picker props + const format = dateFnsFormat ?? getDefaultDateFnsFormat({ timePickerProps, timePrecision }); + return getDateFnsFormatter(format, locale)(date); + } } else { return outOfRangeMessage; } diff --git a/packages/datetime2/src/components/date-range-input3/dateRangeInput3Props.ts b/packages/datetime2/src/components/date-range-input3/dateRangeInput3Props.ts index 95bfbde961..71fa7874fd 100644 --- a/packages/datetime2/src/components/date-range-input3/dateRangeInput3Props.ts +++ b/packages/datetime2/src/components/date-range-input3/dateRangeInput3Props.ts @@ -14,15 +14,36 @@ * limitations under the License. */ -import type { DateRangeInputProps } from "@blueprintjs/datetime"; +import type { DateFormatProps, DateRangeInputProps } from "@blueprintjs/datetime"; import type { DateFnsLocaleProps } from "../../common/dateFnsLocaleProps"; import type { ReactDayPickerRangeProps } from "../../common/reactDayPickerProps"; -/** Props shared between DateRangeInput v1 and v3 */ -type DateRangeInputSharedProps = Omit; +/** + * Props shared between DateRangeInput v1 and v + * + * Note that we exclude formatDate and parseDate so that we can make those optional in DateInput3 and provide a default + * implementation for those functions using date-fns. + */ +type DateRangeInputSharedProps = Omit< + DateRangeInputProps, + "dayPickerProps" | "formatDate" | "locale" | "localeUtils" | "modifiers" | "parseDate" +>; -export type DateRangeInput3Props = DateRangeInputSharedProps & ReactDayPickerRangeProps & DateFnsLocaleProps; +export interface DateRangeInput3Props + extends DateRangeInputSharedProps, + ReactDayPickerRangeProps, + DateFnsLocaleProps, + Partial> { + /** + * [date-fns format](https://date-fns.org/docs/format) string used to format & parse date strings. + * + * Mutually exclusive with the `formatDate` and `parseDate` props. + * + * @see https://date-fns.org/docs/format + */ + dateFnsFormat?: string; +} export type DateRangeInput3DefaultProps = Required< Pick< @@ -34,6 +55,7 @@ export type DateRangeInput3DefaultProps = Required< | "disabled" | "endInputProps" | "invalidDateMessage" + | "locale" | "maxDate" | "minDate" | "outOfRangeMessage" diff --git a/packages/datetime2/src/components/date-range-input3/dateRangeInput3State.ts b/packages/datetime2/src/components/date-range-input3/dateRangeInput3State.ts new file mode 100644 index 0000000000..57fe9d38e4 --- /dev/null +++ b/packages/datetime2/src/components/date-range-input3/dateRangeInput3State.ts @@ -0,0 +1,46 @@ +/* + * Copyright 2023 Palantir Technologies, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Locale } from "date-fns"; + +import type { Boundary } from "@blueprintjs/core"; + +export interface DateRangeInput3State { + isOpen?: boolean; + boundaryToModify?: Boundary; + lastFocusedField?: Boundary; + locale?: Locale | undefined; + + formattedMinDateString?: string; + formattedMaxDateString?: string; + + isStartInputFocused: boolean; + isEndInputFocused: boolean; + + startInputString?: string; + endInputString?: string; + + startHoverString?: string | null; + endHoverString?: string | null; + + selectedEnd: Date | null; + selectedStart: Date | null; + + shouldSelectAfterUpdate?: boolean; + wasLastFocusChangeDueToHover?: boolean; + + selectedShortcutIndex?: number; +} diff --git a/packages/datetime2/src/components/date-range-picker3/dateRangePicker3.tsx b/packages/datetime2/src/components/date-range-picker3/dateRangePicker3.tsx index 12fd259028..21c8638f18 100644 --- a/packages/datetime2/src/components/date-range-picker3/dateRangePicker3.tsx +++ b/packages/datetime2/src/components/date-range-picker3/dateRangePicker3.tsx @@ -19,7 +19,7 @@ import { format } from "date-fns"; import * as React from "react"; import type { DateFormatter, DayModifiers, DayMouseEventHandler, ModifiersClassNames } from "react-day-picker"; -import { AbstractPureComponent, Boundary, DISPLAYNAME_PREFIX, Divider } from "@blueprintjs/core"; +import { Boundary, DISPLAYNAME_PREFIX, Divider } from "@blueprintjs/core"; import { DatePickerShortcutMenu, DatePickerUtils, @@ -33,11 +33,11 @@ import { } from "@blueprintjs/datetime"; import { Classes, dayPickerClassNameOverrides } from "../../classes"; -import { loadDateFnsLocale } from "../../common/dateFnsLocaleUtils"; import { combineModifiers, HOVERED_RANGE_MODIFIER } from "../../common/dayPickerModifiers"; import { DatePicker3Provider } from "../date-picker3/datePicker3Context"; +import { DateFnsLocalizedComponent } from "../dateFnsLocalizedComponent"; import { ContiguousDayRangePicker } from "./contiguousDayRangePicker"; -import type { DateRangePicker3Props } from "./dateRangePicker3Props"; +import type { DateRangePicker3DefaultProps, DateRangePicker3Props } from "./dateRangePicker3Props"; import type { DateRangePicker3State } from "./dateRangePicker3State"; import { NonContiguousDayRangePicker } from "./nonContiguousDayRangePicker"; @@ -50,8 +50,8 @@ const NULL_RANGE: DateRange = [null, null]; * * @see https://blueprintjs.com/docs/#datetime2/date-range-picker3 */ -export class DateRangePicker3 extends AbstractPureComponent { - public static defaultProps: DateRangePicker3Props = { +export class DateRangePicker3 extends DateFnsLocalizedComponent { + public static defaultProps: DateRangePicker3DefaultProps = { allowSingleDayRange: false, contiguousCalendarMonths: true, dayPickerProps: {}, @@ -149,10 +149,12 @@ export class DateRangePicker3 extends AbstractPureComponent( - nextStateOrAction: - | Partial - | null - | (( - prevState: DateRangePicker3State, - prevProps: DateRangePicker3Props, - ) => Pick | null), - callback?: () => void, - ) { - if (typeof nextStateOrAction === "function") { - super.setState(nextStateOrAction, callback); - } else { - super.setState(nextStateOrAction as DateRangePicker3State); - } } protected validateProps(props: DateRangePicker3Props) { @@ -220,22 +199,6 @@ export class DateRangePicker3 extends AbstractPureComponent; export type DateRangePicker3Props = DateRangePickerSharedProps & DateFnsLocaleProps & ReactDayPickerRangeProps; + +export type DateRangePicker3DefaultProps = Required< + Pick< + DateRangePicker3Props, + | "allowSingleDayRange" + | "contiguousCalendarMonths" + | "dayPickerProps" + | "locale" + | "maxDate" + | "minDate" + | "reverseMonthAndYearMenus" + | "shortcuts" + | "singleMonthOnly" + | "timePickerProps" + > +>; + +export type DateRangePicker3PropsWithDefaults = Omit & + DateRangePicker3DefaultProps; diff --git a/packages/datetime2/src/components/dateFnsLocalizedComponent.tsx b/packages/datetime2/src/components/dateFnsLocalizedComponent.tsx new file mode 100644 index 0000000000..66b1c742ed --- /dev/null +++ b/packages/datetime2/src/components/dateFnsLocalizedComponent.tsx @@ -0,0 +1,76 @@ +/* + * Copyright 2023 Palantir Technologies, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Locale } from "date-fns"; + +import { AbstractPureComponent } from "@blueprintjs/core"; + +import type { DateFnsLocaleProps } from "../common/dateFnsLocaleProps"; +import { loadDateFnsLocale } from "../common/dateFnsLocaleUtils"; + +interface DateFnsLocaleState { + locale?: Locale | undefined; +} + +/** + * Abstract component which accepts a date-fns locale prop and loads the corresponding `Locale` object as necessary. + * + * Currently used by DateRangePicker3 and DateRangeInput3, but we would ideally migrate to the `useDateFnsLocale()` + * hook once those components are refactored into functional components. + */ +export abstract class DateFnsLocalizedComponent< + P extends DateFnsLocaleProps, + S extends DateFnsLocaleState, +> extends AbstractPureComponent { + // HACKHACK: type fix for setState which does not accept partial state objects in our outdated version of + // @types/react (v16.14.32) + public setState( + nextStateOrAction: ((prevState: S, prevProps: P) => Pick | null) | Pick | Partial | null, + callback?: () => void, + ) { + if (typeof nextStateOrAction === "function") { + super.setState(nextStateOrAction, callback); + } else { + super.setState(nextStateOrAction as S); + } + } + + public async componentDidMount() { + await this.loadLocale(this.props.locale); + } + + public async componentDidUpdate(prevProps: DateFnsLocaleProps) { + if (this.props.locale !== prevProps.locale) { + await this.loadLocale(this.props.locale); + } + } + + private async loadLocale(localeOrCode: string | Locale | undefined) { + if (localeOrCode === undefined) { + return; + } else if (this.state.locale?.code === localeOrCode) { + return; + } + + if (typeof localeOrCode === "string") { + const loader = this.props.dateFnsLocaleLoader ?? loadDateFnsLocale; + const locale = await loader(localeOrCode); + this.setState({ locale }); + } else { + this.setState({ locale: localeOrCode }); + } + } +} diff --git a/packages/datetime2/test/components/dateRangeInput3Tests.tsx b/packages/datetime2/test/components/dateRangeInput3Tests.tsx index 845e803f85..5dbe002afb 100644 --- a/packages/datetime2/test/components/dateRangeInput3Tests.tsx +++ b/packages/datetime2/test/components/dateRangeInput3Tests.tsx @@ -17,6 +17,7 @@ import { expect } from "chai"; import { format, parse } from "date-fns"; import * as Locales from "date-fns/locale"; +import esLocale from "date-fns/locale/es"; import { mount, ReactWrapper } from "enzyme"; import * as React from "react"; import * as ReactDOM from "react-dom"; @@ -96,10 +97,10 @@ describe("", () => { const START_DATE_2 = new Date(2022, Months.JANUARY, 1); const START_STR_2 = DATE_FORMAT.formatDate(START_DATE_2); - const START_DE_STR_2 = "01.01.2022"; + const START_STR_2_ES_LOCALE = "1 de enero de 2022"; const END_DATE_2 = new Date(2022, Months.JANUARY, 31); const END_STR_2 = DATE_FORMAT.formatDate(END_DATE_2); - const END_DE_STR_2 = "31.01.2022"; + const END_STR_2_ES_LOCALE = "31 de enero de 2022"; const DATE_RANGE_2 = [START_DATE_2, END_DATE_2] as DateRange; const INVALID_STR = ""; @@ -2683,9 +2684,29 @@ describe("", () => { assertInputValuesEqual(root, DEC_2_STR, DEC_8_STR); }); - it.skip("Formats locale-specific format strings properly", () => { - const { root } = wrap(); - assertInputValuesEqual(root, START_DE_STR_2, END_DE_STR_2); + describe("localization", () => { + describe("with formatDate & parseDate undefined", () => { + it("formats date strings with provided Locale object", () => { + const { root } = wrap( + , + true, + ); + assertInputValuesEqual(root, START_STR_2_ES_LOCALE, END_STR_2_ES_LOCALE); + }); + + it("formats date strings with async-loaded locale corresponding to provided locale code", done => { + const { root } = wrap( + , + true, + ); + // give the component one animation frame to load the locale upon mount + setTimeout(() => { + root.update(); + assertInputValuesEqual(root, START_STR_2_ES_LOCALE, END_STR_2_ES_LOCALE); + done(); + }); + }); + }); }); }); diff --git a/packages/docs-app/src/common/dateFnsLocaleSelect.tsx b/packages/docs-app/src/common/dateFnsLocaleSelect.tsx index 37cc245462..24a3ea2ad8 100644 --- a/packages/docs-app/src/common/dateFnsLocaleSelect.tsx +++ b/packages/docs-app/src/common/dateFnsLocaleSelect.tsx @@ -18,7 +18,7 @@ import * as React from "react"; import { Button, MenuItem } from "@blueprintjs/core"; import { CaretDown } from "@blueprintjs/icons"; -import { ItemRenderer, Select } from "@blueprintjs/select"; +import { ItemRenderer, Select, SelectPopoverProps } from "@blueprintjs/select"; export type CommonDateFnsLocale = "de" | "en-US" | "es" | "fr" | "hi" | "it" | "zh-CN"; export const COMMON_DATE_FNS_LOCALES: CommonDateFnsLocale[] = ["de", "en-US", "es", "fr", "hi", "it", "zh-CN"]; @@ -35,6 +35,7 @@ const LOCALE_CODE_TO_NAME: Record = { export interface DateFnsLocaleSelectProps { value: CommonDateFnsLocale; onChange: (newValue: CommonDateFnsLocale) => void; + popoverProps?: SelectPopoverProps["popoverProps"]; } export const DateFnsLocaleSelect: React.FC = props => { @@ -66,7 +67,7 @@ export const DateFnsLocaleSelect: React.FC = props => items={COMMON_DATE_FNS_LOCALES} itemRenderer={renderLocaleItem} onItemSelect={props.onChange} - popoverProps={{ minimal: true, placement: "bottom-end" }} + popoverProps={{ minimal: true, placement: "bottom-end", ...props.popoverProps }} >