diff --git a/packages/emotion/src/version.ts b/packages/emotion/src/version.ts index 86fb165ead..52ba2c39c5 100644 --- a/packages/emotion/src/version.ts +++ b/packages/emotion/src/version.ts @@ -1 +1 @@ -export const VERSION = '5.1.0'; +export const VERSION = '5.2.0'; diff --git a/packages/time-input/src/Context/TimeInputDisplayContext/TimeInputDisplayContext.tsx b/packages/time-input/src/Context/TimeInputDisplayContext/TimeInputDisplayContext.tsx index 3f069ddaae..830893676c 100644 --- a/packages/time-input/src/Context/TimeInputDisplayContext/TimeInputDisplayContext.tsx +++ b/packages/time-input/src/Context/TimeInputDisplayContext/TimeInputDisplayContext.tsx @@ -6,6 +6,8 @@ import React, { } from 'react'; import defaults from 'lodash/defaults'; +import { getFormatParts, hasDayPeriod } from '../../utils'; + import { TimeInputDisplayContextProps, TimeInputDisplayProviderProps, @@ -39,6 +41,14 @@ export const TimeInputDisplayProvider = ({ // TODO: min, max helpers + // Determines if the input should show a select for the day period (AM/PM) + const is12hFormat = hasDayPeriod(providerValue.locale); + + // Only used to track the presentation format of the segments, not the value itself + const formatParts = getFormatParts({ + showSeconds: providerValue.showSeconds, + }); + return ( {children} diff --git a/packages/time-input/src/Context/TimeInputDisplayContext/TimeInputDisplayContext.types.ts b/packages/time-input/src/Context/TimeInputDisplayContext/TimeInputDisplayContext.types.ts index 6a77f2c07c..1a438e3729 100644 --- a/packages/time-input/src/Context/TimeInputDisplayContext/TimeInputDisplayContext.types.ts +++ b/packages/time-input/src/Context/TimeInputDisplayContext/TimeInputDisplayContext.types.ts @@ -38,6 +38,17 @@ export type TimeInputDisplayContextProps = Omit< * Setter for whether the input has been interacted with */ setIsDirty: React.Dispatch>; + + /** + * Whether the AM/PM select should be shown + */ + is12hFormat: boolean; + + /** + * An array of {@link Intl.DateTimeFormatPart}, + * used to determine the order of segments in the input + */ + formatParts?: Array; }; /** diff --git a/packages/time-input/src/Context/TimeInputDisplayContext/TimePickerDisplayContext.utils.ts b/packages/time-input/src/Context/TimeInputDisplayContext/TimePickerDisplayContext.utils.ts index 12a26a9e7f..9f178ddb6d 100644 --- a/packages/time-input/src/Context/TimeInputDisplayContext/TimePickerDisplayContext.utils.ts +++ b/packages/time-input/src/Context/TimeInputDisplayContext/TimePickerDisplayContext.utils.ts @@ -31,6 +31,7 @@ export const displayContextPropNames: Array = [ 'size', 'errorMessage', 'state', + 'showSeconds', ]; /** @@ -51,4 +52,6 @@ export const defaultTimeInputDisplayContext: TimeInputDisplayContextProps = { errorMessage: '', isDirty: false, setIsDirty: () => {}, + is12hFormat: false, + showSeconds: true, }; diff --git a/packages/time-input/src/TimeInput.stories.tsx b/packages/time-input/src/TimeInput.stories.tsx index 1b0411f54e..005a8751e0 100644 --- a/packages/time-input/src/TimeInput.stories.tsx +++ b/packages/time-input/src/TimeInput.stories.tsx @@ -1,7 +1,9 @@ -import React from 'react'; +import React, { useState } from 'react'; import { type StoryMetaType } from '@lg-tools/storybook-utils'; import { StoryFn } from '@storybook/react'; +import { DateType, SupportedLocales } from '@leafygreen-ui/date-utils'; + import { TimeInput } from '.'; const meta: StoryMetaType = { @@ -9,11 +11,42 @@ const meta: StoryMetaType = { component: TimeInput, parameters: { default: 'LiveExample', + controls: { + exclude: [ + 'handleValidation', + 'initialValue', + 'onChange', + 'onDateChange', + 'onSegmentChange', + 'value', + 'onTimeChange', + ], + }, + }, + args: { + showSeconds: true, + locale: SupportedLocales.ISO_8601, + timeZone: 'UTC', + }, + argTypes: { + locale: { control: 'select', options: Object.values(SupportedLocales) }, + timeZone: { + control: 'select', + options: [undefined, 'UTC', 'America/New_York', 'Europe/London'], + }, }, }; export default meta; -const Template: StoryFn = props => ; +const Template: StoryFn = props => { + const [value, setValue] = useState( + new Date('1990-02-20T14:30:50Z'), + ); + + return ( + setValue(time)} /> + ); +}; export const LiveExample = Template.bind({}); diff --git a/packages/time-input/src/TimeInput/TimeInput.types.ts b/packages/time-input/src/TimeInput/TimeInput.types.ts index d784cd3c80..cd9268318a 100644 --- a/packages/time-input/src/TimeInput/TimeInput.types.ts +++ b/packages/time-input/src/TimeInput/TimeInput.types.ts @@ -78,6 +78,13 @@ export type DisplayTimeInputProps = { * A message to show in red underneath the input when state is `Error` */ errorMessage?: string; + + /** + * Whether to show seconds in the input. + * + * @default true + */ + showSeconds?: boolean; } & DarkModeProps & AriaLabelPropsWithLabel; diff --git a/packages/time-input/src/TimeInputInputs/TimeInputInputs.tsx b/packages/time-input/src/TimeInputInputs/TimeInputInputs.tsx index 3be13b2b50..12e186c411 100644 --- a/packages/time-input/src/TimeInputInputs/TimeInputInputs.tsx +++ b/packages/time-input/src/TimeInputInputs/TimeInputInputs.tsx @@ -4,6 +4,7 @@ import { cx } from '@leafygreen-ui/emotion'; import { FormField, FormFieldInputContainer } from '@leafygreen-ui/form-field'; import { unitOptions } from '../constants'; +import { useTimeInputDisplayContext } from '../Context/TimeInputDisplayContext/TimeInputDisplayContext'; import { TimeInputSelect } from '../TimeInputSelect/TimeInputSelect'; import { UnitOption } from '../TimeInputSelect/TimeInputSelect.types'; @@ -15,6 +16,7 @@ import { TimeInputInputsProps } from './TimeInputInputs.types'; */ export const TimeInputInputs = forwardRef( (_props: TimeInputInputsProps, forwardedRef) => { + const { is12hFormat } = useTimeInputDisplayContext(); const [selectUnit, setSelectUnit] = useState(unitOptions[0]); const handleSelectChange = (unit: UnitOption) => { @@ -28,12 +30,14 @@ export const TimeInputInputs = forwardRef(
TODO: Input segments go here
- { - handleSelectChange(unit); - }} - /> + {is12hFormat && ( + { + handleSelectChange(unit); + }} + /> + )} ); diff --git a/packages/time-input/src/constants.ts b/packages/time-input/src/constants.ts index afb7f735b4..b751c69a86 100644 --- a/packages/time-input/src/constants.ts +++ b/packages/time-input/src/constants.ts @@ -1,4 +1,16 @@ +import { DateTimeParts } from './shared.types'; + export const unitOptions = [ { displayName: 'AM', value: 'AM' }, { displayName: 'PM', value: 'PM' }, ]; + +export const defaultDateTimeParts: DateTimeParts = { + hour: '', + minute: '', + second: '', + month: '', + day: '', + year: '', + dayPeriod: 'AM', +}; diff --git a/packages/time-input/src/shared.types.ts b/packages/time-input/src/shared.types.ts new file mode 100644 index 0000000000..fc03aea69a --- /dev/null +++ b/packages/time-input/src/shared.types.ts @@ -0,0 +1,14 @@ +export const DateTimePartKeys = { + hour: 'hour', + minute: 'minute', + second: 'second', + month: 'month', + day: 'day', + year: 'year', + dayPeriod: 'dayPeriod', +} as const; + +export type DateTimePartKeys = + (typeof DateTimePartKeys)[keyof typeof DateTimePartKeys]; + +export type DateTimeParts = Record; diff --git a/packages/time-input/src/utils/getFormatParts/getFormatParts.spec.ts b/packages/time-input/src/utils/getFormatParts/getFormatParts.spec.ts new file mode 100644 index 0000000000..39cb06550b --- /dev/null +++ b/packages/time-input/src/utils/getFormatParts/getFormatParts.spec.ts @@ -0,0 +1,23 @@ +import { getFormatParts } from './getFormatParts'; + +describe('packages/time-input/utils/getFormatParts', () => { + test('returns the correct format parts without seconds', () => { + const formatParts = getFormatParts({}); + expect(formatParts).toEqual([ + { type: 'hour', value: '' }, + { type: 'literal', value: ':' }, + { type: 'minute', value: '' }, + ]); + }); + + test('returns the correct format parts with seconds', () => { + const formatParts = getFormatParts({ showSeconds: true }); + expect(formatParts).toEqual([ + { type: 'hour', value: '' }, + { type: 'literal', value: ':' }, + { type: 'minute', value: '' }, + { type: 'literal', value: ':' }, + { type: 'second', value: '' }, + ]); + }); +}); diff --git a/packages/time-input/src/utils/getFormatParts/getFormatParts.ts b/packages/time-input/src/utils/getFormatParts/getFormatParts.ts new file mode 100644 index 0000000000..506dffd22c --- /dev/null +++ b/packages/time-input/src/utils/getFormatParts/getFormatParts.ts @@ -0,0 +1,43 @@ +/** + * Returns an array of {@link Intl.DateTimeFormatPart} for the provided locale. + * + * Filters out the dayPeriod and the empty literal before it + * since they are not part of the time format parts. + * + * This will return `:` for every literal part regardless of the locale. + * + * @param showSeconds - Whether to show seconds + * @returns The format parts + * + * @example + * + * ```js + * getFormatParts({ showSeconds: true }); + * + * // [ + * // { type: 'hour', value: '' }, + * // { type: 'literal', value: ':' }, + * // { type: 'minute', value: '' }, + * // { type: 'literal', value: ':' }, + * // { type: 'second', value: '' }, + * // ] + */ +export const getFormatParts = ({ + showSeconds = false, +}: { + showSeconds?: boolean; +}): Array | undefined => { + const formatParts: Array = [ + { type: 'hour', value: '' }, + { type: 'literal', value: ':' }, + { type: 'minute', value: '' }, + ...(showSeconds + ? ([ + { type: 'literal', value: ':' }, + { type: 'second', value: '' }, + ] as Array) + : []), + ]; + + return formatParts; +}; diff --git a/packages/time-input/src/utils/getFormatPartsValues/getFormatPartsValues.spec.ts b/packages/time-input/src/utils/getFormatPartsValues/getFormatPartsValues.spec.ts new file mode 100644 index 0000000000..8091def990 --- /dev/null +++ b/packages/time-input/src/utils/getFormatPartsValues/getFormatPartsValues.spec.ts @@ -0,0 +1,195 @@ +import { Month, SupportedLocales } from '@leafygreen-ui/date-utils'; + +import { getFormatPartsValues } from './getFormatPartsValues'; + +describe('packages/time-input/utils/getFormatPartsValues', () => { + describe('returns the correct values', () => { + beforeEach(() => { + // Mock the current date/time in UTC + jest.useFakeTimers().setSystemTime( + new Date(Date.UTC(2025, Month.January, 1, 0, 0, 0)), // January 1, 2025 00:00:00 UTC + ); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe.each([undefined, new Date('invalid')])( + 'when the value is %p', + value => { + describe('and the time zone is', () => { + test('UTC', () => { + const formatPartsValues = getFormatPartsValues({ + locale: SupportedLocales.ISO_8601, + timeZone: 'UTC', + value, + }); + // January 1, 2025 00:00:00 UTC in UTC is January 1, 2025 00:00:00 (UTC) + expect(formatPartsValues).toEqual({ + hour: '', + minute: '', + second: '', + month: '1', + day: '1', + year: '2025', + dayPeriod: 'AM', // This is the default value for the day period since iso-8601 is 24h format + }); + }); + + test('America/New_York', () => { + const formatPartsValues = getFormatPartsValues({ + locale: SupportedLocales.ISO_8601, + timeZone: 'America/New_York', + value, + }); + // January 1, 2025 00:00:00 UTC in America/New_York is December 31, 2024 19:00:00 (UTC-5 hours) + expect(formatPartsValues).toEqual({ + hour: '', + minute: '', + second: '', + month: '12', + day: '31', + year: '2024', + dayPeriod: 'AM', // This is the default value for the day period since iso is 24h format + }); + }); + + test('Pacific/Auckland', () => { + const formatPartsValues = getFormatPartsValues({ + locale: SupportedLocales.ISO_8601, + timeZone: 'Pacific/Auckland', + value, + }); + // January 1, 2025 00:00:00 UTC in Pacific/Auckland is January 1, 2025 (UTC+13 hours) + expect(formatPartsValues).toEqual({ + hour: '', + minute: '', + second: '', + month: '1', + day: '1', + year: '2025', + dayPeriod: 'AM', + }); + }); + }); + }, + ); + + describe('when the value is defined', () => { + const utcValue = new Date(Date.UTC(2025, Month.February, 20, 13, 30, 59)); // February 20, 2025 13:30:59 UTC + + describe('and the time zone is', () => { + describe('UTC', () => { + test('24 hour format', () => { + const formatPartsValues = getFormatPartsValues({ + locale: SupportedLocales.ISO_8601, + timeZone: 'UTC', + value: utcValue, + }); + expect(formatPartsValues).toEqual({ + hour: '13', + minute: '30', + second: '59', + month: '2', + day: '20', + year: '2025', + dayPeriod: 'AM', + }); + }); + + test('12 hour format', () => { + const formatPartsValues = getFormatPartsValues({ + locale: SupportedLocales.en_US, + timeZone: 'UTC', + value: utcValue, + }); + expect(formatPartsValues).toEqual({ + hour: '1', + minute: '30', + second: '59', + month: '2', + day: '20', + year: '2025', + dayPeriod: 'PM', + }); + }); + }); + + describe('America/New_York', () => { + test('24 hour format', () => { + const formatPartsValues = getFormatPartsValues({ + locale: SupportedLocales.ISO_8601, + timeZone: 'America/New_York', + value: utcValue, + }); + // February 20, 2025 13:30:59 UTC in America/New_York is 08:30:59 (UTC-5 hours) + expect(formatPartsValues).toEqual({ + hour: '08', + minute: '30', + second: '59', + month: '2', + day: '20', + year: '2025', + dayPeriod: 'AM', + }); + }); + test('12 hour format', () => { + const formatPartsValues = getFormatPartsValues({ + locale: SupportedLocales.en_US, + timeZone: 'America/New_York', + value: utcValue, + }); + // February 20, 2025 13:30:59 UTC in America/New_York is 8:30:59 AM (UTC-5 hours) + expect(formatPartsValues).toEqual({ + hour: '8', + minute: '30', + second: '59', + month: '2', + day: '20', + year: '2025', + dayPeriod: 'AM', + }); + }); + }); + + describe('Pacific/Auckland', () => { + test('24 hour format', () => { + const formatPartsValues = getFormatPartsValues({ + locale: SupportedLocales.ISO_8601, + timeZone: 'Pacific/Auckland', + value: utcValue, + }); + // February 20, 2025 13:30:59 UTC in Pacific/Auckland is February 21, 2025 02:30:59 (UTC+13 hours) + expect(formatPartsValues).toEqual({ + hour: '02', + minute: '30', + second: '59', + month: '2', + day: '21', + year: '2025', + dayPeriod: 'AM', + }); + }); + test('12 hour format', () => { + const formatPartsValues = getFormatPartsValues({ + locale: SupportedLocales.en_US, + timeZone: 'Pacific/Auckland', + value: utcValue, + }); + // February 20, 2025 13:30:59 UTC in Pacific/Auckland is February 21, 2025 2:30:59 AM (UTC+13 hours) + expect(formatPartsValues).toEqual({ + hour: '2', + minute: '30', + second: '59', + month: '2', + day: '21', + year: '2025', + dayPeriod: 'AM', + }); + }); + }); + }); + }); + }); +}); diff --git a/packages/time-input/src/utils/getFormatPartsValues/getFormatPartsValues.ts b/packages/time-input/src/utils/getFormatPartsValues/getFormatPartsValues.ts new file mode 100644 index 0000000000..50371fb422 --- /dev/null +++ b/packages/time-input/src/utils/getFormatPartsValues/getFormatPartsValues.ts @@ -0,0 +1,69 @@ +import { DateType, isValidDate, LocaleString } from '@leafygreen-ui/date-utils'; + +import { DateTimeParts } from '../../shared.types'; +import { getFormatter } from '../getFormatter/getFormatter'; + +import { getFormattedDateTimeParts } from './getFormattedDateTimeParts/getFormattedDateTimeParts'; + +/** + * Returns the format parts values for the given locale, time zone, and value. + * @param locale - The locale to get the format parts values for + * @param timeZone - The time zone to get the format parts values for + * @param value - The value to get the format parts values for + * @returns The format parts values + * + * @example + * ```js + * getFormatPartsValues({ + * locale: 'en-US', + * timeZone: 'America/New_York', + * value: new Date('2025-01-01T12:00:00Z'), + * }); + * // returns: { hour: '12', minute: '00', second: '00', month: '01', day: '01', year: '2025', dayPeriod: 'PM' } + * ``` + */ +export const getFormatPartsValues = ({ + locale, + timeZone, + value, +}: { + locale: LocaleString; + timeZone: string; + value: DateType | undefined; +}): DateTimeParts => { + const isValueValid = isValidDate(value); + + // Get the formatter that returns day, month, year, hour, minute, and second for the given locale and time zone. + const formatter = getFormatter({ + locale, + options: { + timeZone, + year: 'numeric', + month: 'numeric', + day: 'numeric', + // if the value is not valid then we don't want to return hour, minute, and second but we still want to return day, month, and year. + ...(isValueValid + ? { + hour: 'numeric', + minute: 'numeric', + second: 'numeric', + } + : {}), + }, + }); + + // This returns the day, month, year, hour, minute, and second based on the value. + const dateTimeParts = formatter?.formatToParts( + isValueValid ? value : new Date(), + ); + + const filteredDateTimeParts = + dateTimeParts?.filter(part => part.type !== 'literal') ?? []; + + // this adds a default value for the day period if it is not present. It's not necessary for 24h format locales but we add it for consistency. + const formattedDateTimeParts = getFormattedDateTimeParts( + filteredDateTimeParts, + ); + + return formattedDateTimeParts; +}; diff --git a/packages/time-input/src/utils/getFormatPartsValues/getFormattedDateTimeParts/getFormattedDateTimeParts.spec.ts b/packages/time-input/src/utils/getFormatPartsValues/getFormattedDateTimeParts/getFormattedDateTimeParts.spec.ts new file mode 100644 index 0000000000..219e13498f --- /dev/null +++ b/packages/time-input/src/utils/getFormatPartsValues/getFormattedDateTimeParts/getFormattedDateTimeParts.spec.ts @@ -0,0 +1,54 @@ +import { getFormattedDateTimeParts } from './getFormattedDateTimeParts'; + +describe('packages/time-input/utils/getFormattedDateTimeParts', () => { + test('returns the formatted date time parts with the default date time parts', () => { + const formattedDateTimeParts = getFormattedDateTimeParts([ + { type: 'day', value: '12' }, + { type: 'month', value: '01' }, + { type: 'year', value: '2025' }, + ]); + expect(formattedDateTimeParts).toEqual({ + day: '12', + month: '01', + year: '2025', + hour: '', + minute: '', + second: '', + dayPeriod: 'AM', + }); + }); + + test('returns the formatted time parts without the default time parts', () => { + const formattedDateTimeParts = getFormattedDateTimeParts([ + { type: 'hour', value: '12' }, + { type: 'minute', value: '30' }, + { type: 'second', value: '00' }, + { type: 'month', value: '01' }, + { type: 'day', value: '01' }, + { type: 'year', value: '2025' }, + { type: 'dayPeriod', value: 'PM' }, + ]); + expect(formattedDateTimeParts).toEqual({ + hour: '12', + minute: '30', + second: '00', + month: '01', + day: '01', + year: '2025', + dayPeriod: 'PM', + }); + }); + + test('returns the formatted time parts with the default time parts when time parts is an empty array', () => { + const formattedDateTimeParts = getFormattedDateTimeParts([]); + expect(formattedDateTimeParts).toEqual({ + hour: '', + minute: '', + second: '', + month: '', + day: '', + year: '', + dayPeriod: 'AM', + }); + }); +}); diff --git a/packages/time-input/src/utils/getFormatPartsValues/getFormattedDateTimeParts/getFormattedDateTimeParts.ts b/packages/time-input/src/utils/getFormatPartsValues/getFormattedDateTimeParts/getFormattedDateTimeParts.ts new file mode 100644 index 0000000000..301659a153 --- /dev/null +++ b/packages/time-input/src/utils/getFormatPartsValues/getFormattedDateTimeParts/getFormattedDateTimeParts.ts @@ -0,0 +1,49 @@ +import defaultsDeep from 'lodash/defaultsDeep'; + +import { defaultDateTimeParts } from '../../../constants'; +import { DateTimePartKeys, DateTimeParts } from '../../../shared.types'; + +/** + * Returns the formatted date time parts. + * + * This merges the formatted date time parts with the default date time parts. E.g., when the component is uncontrolled, and the value is undefined, we set empty defaults for the hour, minute, and second. + * + * @param dateTimeParts - The date time parts to get the formatted and merged date time parts for + * @returns The formatted and merged date time parts + * + * @example + * ```js + * getFormattedDateTimeParts([ + * { type: 'day', value: '12' }, + * { type: 'month', value: '01' }, + * { type: 'year', value: '2025' }, + * ]); + * // returns: { + * // day: '12', + * // month: '01', + * // year: '2025', + * // hour: '', + * // minute: '', + * // second: '', + * // dayPeriod: 'AM' + * // } + * ``` + */ +export const getFormattedDateTimeParts = ( + dateTimeParts: Array, +): DateTimeParts => { + const formattedDateTimeParts: DateTimeParts = dateTimeParts.reduce( + (acc, part) => { + acc[part.type as DateTimePartKeys] = part.value; + return acc; + }, + {} as DateTimeParts, + ); + + const mergedTimeParts: DateTimeParts = defaultsDeep( + formattedDateTimeParts, + defaultDateTimeParts, + ); + + return mergedTimeParts; +}; diff --git a/packages/time-input/src/utils/getFormatPartsValues/index.ts b/packages/time-input/src/utils/getFormatPartsValues/index.ts new file mode 100644 index 0000000000..66a9a23845 --- /dev/null +++ b/packages/time-input/src/utils/getFormatPartsValues/index.ts @@ -0,0 +1 @@ +export { getFormatPartsValues } from './getFormatPartsValues'; diff --git a/packages/time-input/src/utils/getFormatter/getFormatter.spec.ts b/packages/time-input/src/utils/getFormatter/getFormatter.spec.ts new file mode 100644 index 0000000000..cc2d0f2cf4 --- /dev/null +++ b/packages/time-input/src/utils/getFormatter/getFormatter.spec.ts @@ -0,0 +1,52 @@ +import { SupportedLocales } from '@leafygreen-ui/date-utils'; + +import { getFormatter } from './getFormatter'; + +describe('packages/time-input/utils/getFormatter', () => { + test('returns a formatter for a valid locale', () => { + const formatter = getFormatter({ locale: SupportedLocales.en_US }); + expect(formatter).toBeDefined(); + }); + + test('returns a formatter for iso-8601', () => { + const formatter = getFormatter({ locale: SupportedLocales.ISO_8601 }); + expect(formatter).toBeDefined(); + }); + + test('returns a formatter if locale is not provided', () => { + const formatter = getFormatter({}); + expect(formatter).toBeDefined(); + }); + + test('returns undefined for an invalid locale', () => { + const formatter = getFormatter({ locale: '!!!' }); + expect(formatter).toBeUndefined(); + }); + + test('returns undefined for an an empty string locale', () => { + const formatter = getFormatter({ locale: '' }); + expect(formatter).toBeUndefined(); + }); + + describe('formatter can ', () => { + test('format dates', () => { + const testDate = new Date('2025-01-15T14:30:00Z'); + const formatter = getFormatter({ locale: SupportedLocales.en_US }); + const formatted = formatter?.format(testDate); + expect(formatted).toBe('1/15/2025'); + }); + + test('formatToParts', () => { + const testDate = new Date('2025-01-15T14:30:00Z'); + const formatter = getFormatter({ locale: SupportedLocales.en_US }); + const formatParts = formatter?.formatToParts(testDate); + expect(formatParts).toEqual([ + { type: 'month', value: '1' }, + { type: 'literal', value: '/' }, + { type: 'day', value: '15' }, + { type: 'literal', value: '/' }, + { type: 'year', value: '2025' }, + ]); + }); + }); +}); diff --git a/packages/time-input/src/utils/getFormatter/getFormatter.ts b/packages/time-input/src/utils/getFormatter/getFormatter.ts new file mode 100644 index 0000000000..d4cbc27883 --- /dev/null +++ b/packages/time-input/src/utils/getFormatter/getFormatter.ts @@ -0,0 +1,43 @@ +import { + isValidLocale, + LocaleString, + SupportedLocales, +} from '@leafygreen-ui/date-utils'; + +/** + * Returns a formatter for the given locale. + * + * If the locale is iso-180, we explicitly set the hour cycle to 24h(`hourCycle: 'h23'`). + * + * @param locale - The locale to get the formatter for + * @param options - The options to configure the formatter. {@link Intl.DateTimeFormatOptions} + * + * @returns Returns an object ({@link Intl.DateTimeFormat}) for the given locale that includes methods to format dates and time parts, such as `format()` and `formatToParts()`. + * + * @example + * ```js + * const formatter = getFormatter({ locale: 'en-US' }); + * formatter.format(new Date('2025-01-15T14:30:00Z')); // '1/15/2025' + * formatter.formatToParts(new Date('2025-01-15T14:30:00Z')); // [ { type: 'month', value: '1' }, { type: 'literal', value: '/' }, { type: 'day', value: '15' }, { type: 'literal', value: '/' }, { type: 'year', value: '2025' } ] + * ``` + */ +export const getFormatter = ({ + locale = SupportedLocales.ISO_8601, + options = {}, +}: { + locale?: LocaleString; + options?: Intl.DateTimeFormatOptions; +}) => { + const isIsoLocale = locale === SupportedLocales.ISO_8601; + const isValidNonIsoLocale = isValidLocale(locale); + + if (!isValidNonIsoLocale && !isIsoLocale) { + return undefined; + } + + // If the locale is iso-8601, the default locale of the runtime environment is used, which is fine since we can explicitly set the format to 24h + return new Intl.DateTimeFormat(locale, { + ...(isIsoLocale ? { hourCycle: 'h23' } : {}), + ...options, + }); +}; diff --git a/packages/time-input/src/utils/hasDayPeriod/hasDayPeriod.spec.ts b/packages/time-input/src/utils/hasDayPeriod/hasDayPeriod.spec.ts new file mode 100644 index 0000000000..500f69e35d --- /dev/null +++ b/packages/time-input/src/utils/hasDayPeriod/hasDayPeriod.spec.ts @@ -0,0 +1,17 @@ +import { SupportedLocales } from '@leafygreen-ui/date-utils'; + +import { hasDayPeriod } from './hasDayPeriod'; + +describe('packages/time-input/utils/hasDayPeriod', () => { + test('returns false for ISO_8601', () => { + expect(hasDayPeriod(SupportedLocales.ISO_8601)).toBe(false); + }); + + test('returns true for en-US', () => { + expect(hasDayPeriod(SupportedLocales.en_US)).toBe(true); + }); + + test('returns false for en-GB', () => { + expect(hasDayPeriod(SupportedLocales.en_GB)).toBe(false); + }); +}); diff --git a/packages/time-input/src/utils/hasDayPeriod/hasDayPeriod.ts b/packages/time-input/src/utils/hasDayPeriod/hasDayPeriod.ts new file mode 100644 index 0000000000..6e198b0e92 --- /dev/null +++ b/packages/time-input/src/utils/hasDayPeriod/hasDayPeriod.ts @@ -0,0 +1,38 @@ +import { LocaleString, SupportedLocales } from '@leafygreen-ui/date-utils'; + +import { DateTimePartKeys } from '../../shared.types'; +import { getFormatter } from '../getFormatter/getFormatter'; + +/** + * Checks if the locale has a day period (AM/PM) + * + * @param locale - The locale to check + * @returns Whether the locale has a day period (AM/PM) + * + * @default false + * + * @example + * ```js + * hasDayPeriod('en-US'); // true + * hasDayPeriod('en-GB'); // false + * hasDayPeriod('iso-8601'); // false + * ``` + */ +export const hasDayPeriod = (locale: LocaleString) => { + if (locale === SupportedLocales.ISO_8601) return false; + + const formatter = getFormatter({ + locale, + options: { hour: 'numeric', minute: 'numeric' }, + }); + + if (!formatter) return false; + + // Format a sample time and check for dayPeriod (AM/PM) + const parts = formatter.formatToParts(new Date()); + const hasDayPeriod = parts.some( + part => part.type === DateTimePartKeys.dayPeriod, + ); + + return hasDayPeriod; +}; diff --git a/packages/time-input/src/utils/index.ts b/packages/time-input/src/utils/index.ts new file mode 100644 index 0000000000..a1ba1765af --- /dev/null +++ b/packages/time-input/src/utils/index.ts @@ -0,0 +1,4 @@ +export { getFormatParts } from './getFormatParts/getFormatParts'; +export { getFormatPartsValues } from './getFormatPartsValues'; +export { getFormatter } from './getFormatter/getFormatter'; +export { hasDayPeriod } from './hasDayPeriod/hasDayPeriod';