diff --git a/CHANGELOG.md b/CHANGELOG.md index 61cf7a2e8..e6c459842 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- `@lumx/react` no long depend on `moment` or `moment-range` to generate the date picker. +- Deprecated `@lumx/core/js/date-picker` functions that **will be removed in the next major version** along with `moment` and `moment-range`. +- DatePicker & DatePickerField: `locale` prop is now optional (uses browser locale by default) + ## [3.5.3][] - 2023-08-30 ### Changed @@ -1807,8 +1813,6 @@ _Failed released_ [3.5.0]: https://github.com/lumapps/design-system/tree/v3.5.0 [unreleased]: https://github.com/lumapps/design-system/compare/v3.5.1...HEAD [3.5.1]: https://github.com/lumapps/design-system/tree/v3.5.1 - - -[Unreleased]: https://github.com/lumapps/design-system/compare/v3.5.3...HEAD +[unreleased]: https://github.com/lumapps/design-system/compare/v3.5.3...HEAD [3.5.3]: https://github.com/lumapps/design-system/compare/v3.5.2...v3.5.3 [3.5.2]: https://github.com/lumapps/design-system/tree/v3.5.2 diff --git a/packages/lumx-core/src/js/date-picker.ts b/packages/lumx-core/src/js/date-picker.ts index 2f41e6055..1d223d34d 100644 --- a/packages/lumx-core/src/js/date-picker.ts +++ b/packages/lumx-core/src/js/date-picker.ts @@ -16,6 +16,8 @@ interface AnnotatedDate { /** * Get the list of days in a week based on locale. * + * @deprecated will be removed in next major version along with the removal of moment (no replacement planned) + * * @param locale The locale using to generate the order of days in a week. * @return The list of days in a week based on locale. */ @@ -26,6 +28,8 @@ export function getWeekDays(locale: string): Moment[] { /** * Get month calendar based on locale and start date. * + * @deprecated will be removed in next major version along with the removal of moment (no replacement planned) + * * @param locale The locale using to generate the order of days in a week. * @param selectedMonth The selected month. * @return The list of days in a week based on locale. @@ -44,6 +48,8 @@ export function getMonthCalendar(locale: string, selectedMonth?: Moment): Moment * Get month calendar based on locale and start date. * Each day is annotated to know if they are displayed and/or clickable. * + * @deprecated will be removed in next major version along with the removal of moment (no replacement planned) + * * @param locale The locale using to generate the order of days in a week. * @param minDate The first selectable date. * @param maxDate The last selectable date. diff --git a/packages/lumx-react/.storybook/preview.ts b/packages/lumx-react/.storybook/preview.ts index 4cfb7f20c..367d84bd4 100644 --- a/packages/lumx-react/.storybook/preview.ts +++ b/packages/lumx-react/.storybook/preview.ts @@ -2,11 +2,6 @@ import type { Preview } from '@storybook/react'; import { withStoryBlockDecorator } from './story-block/decorator'; import { Theme } from '@lumx/react'; -/** - * Import non default language to test moment local change. - */ -import 'moment/dist/locale/fr'; - const preview: Preview = { globalTypes: { /** Add Theme switcher in the toolbar */ diff --git a/packages/lumx-react/package.json b/packages/lumx-react/package.json index b0f10bbef..cace0bbf8 100644 --- a/packages/lumx-react/package.json +++ b/packages/lumx-react/package.json @@ -79,8 +79,6 @@ }, "peerDependencies": { "lodash": "4.17.21", - "moment": ">= 2", - "moment-range": "^4.0.2", "react": ">= 16.13.0", "react-dom": ">= 16.13.0" }, diff --git a/packages/lumx-react/src/components/date-picker/DatePicker.stories.tsx b/packages/lumx-react/src/components/date-picker/DatePicker.stories.tsx new file mode 100644 index 000000000..aa32b9001 --- /dev/null +++ b/packages/lumx-react/src/components/date-picker/DatePicker.stories.tsx @@ -0,0 +1,38 @@ +import { DatePicker, GridColumn } from '@lumx/react'; +import { withValueOnChange } from '@lumx/react/stories/decorators/withValueOnChange'; +import { withNestedProps } from '@lumx/react/stories/decorators/withNestedProps'; +import { withCombinations } from '@lumx/react/stories/decorators/withCombinations'; +import { withWrapper } from '@lumx/react/stories/decorators/withWrapper'; + +export default { + title: 'LumX components/date-picker/DatePicker', + component: DatePicker, + argTypes: { + onChange: { action: true }, + }, + decorators: [withValueOnChange(), withNestedProps()], +}; + +/** + * Default date picker + */ +export const Default = { + args: { + defaultMonth: new Date('2023-02'), + 'nextButtonProps.label': 'Next month', + 'previousButtonProps.label': 'Previous month', + }, +}; + +/** + * Demonstrate variations based on the given locale code + */ +export const LocalesVariations = { + ...Default, + decorators: [ + withCombinations({ + combinations: { sections: { key: 'locale', options: ['fr', 'en-US', 'ar', 'zh-HK', 'ar-eg'] } }, + }), + withWrapper({ maxColumns: 5, itemMinWidth: 300 }, GridColumn), + ], +}; diff --git a/packages/lumx-react/src/components/date-picker/DatePicker.tsx b/packages/lumx-react/src/components/date-picker/DatePicker.tsx index 43672577a..9969d06d7 100644 --- a/packages/lumx-react/src/components/date-picker/DatePicker.tsx +++ b/packages/lumx-react/src/components/date-picker/DatePicker.tsx @@ -1,6 +1,7 @@ -import moment from 'moment'; import React, { forwardRef, useState } from 'react'; import { Comp } from '@lumx/react/utils/type'; +import { addMonthResetDay } from '@lumx/react/utils/date/addMonthResetDay'; +import { isDateValid } from '@lumx/react/utils/date/isDateValid'; import { CLASSNAME, COMPONENT_NAME } from './constants'; import { DatePickerControlled } from './DatePickerControlled'; import { DatePickerProps } from './types'; @@ -14,17 +15,13 @@ import { DatePickerProps } from './types'; */ export const DatePicker: Comp = forwardRef((props, ref) => { const { defaultMonth, locale, value, onChange, ...forwardedProps } = props; - let castedValue; - if (value) { - castedValue = moment(value); - } else if (defaultMonth) { - castedValue = moment(defaultMonth); - } - if (castedValue && !castedValue.isValid()) { + + let referenceDate = value || defaultMonth || new Date(); + if (!isDateValid(referenceDate)) { // eslint-disable-next-line no-console - console.warn(`[@lumx/react/DatePicker] Invalid date provided ${castedValue}`); + console.warn(`[@lumx/react/DatePicker] Invalid date provided ${referenceDate}`); + referenceDate = new Date(); } - const selectedDay = castedValue && castedValue.isValid() ? castedValue : moment(); const [monthOffset, setMonthOffset] = useState(0); @@ -36,7 +33,7 @@ export const DatePicker: Comp = forwardRef((pro setMonthOffset(0); }; - const selectedMonth = moment(selectedDay).locale(locale).add(monthOffset, 'months').toDate(); + const selectedMonth = addMonthResetDay(referenceDate, monthOffset); return ( = forwardRef((props, ref) => { const { - locale, + locale = getCurrentLocale(), maxDate, minDate, nextButtonProps, @@ -45,14 +48,11 @@ export const DatePickerControlled: Comp { - return getAnnotatedMonthCalendar(locale, minDate, maxDate, moment(selectedMonth)); + const { weeks, weekDays } = React.useMemo(() => { + const localeObj = parseLocale(locale) as Locale; + return getMonthCalendar(localeObj, selectedMonth, minDate, maxDate); }, [locale, minDate, maxDate, selectedMonth]); - const weekDays = React.useMemo(() => { - return getWeekDays(locale); - }, [locale]); - return (
- {moment(selectedMonth).locale(locale).format('MMMM YYYY')} + {selectedMonth.toLocaleDateString(locale, { year: 'numeric', month: 'long' })} } />
- {weekDays.map((weekDay) => ( -
- - {weekDay.format('dddd').slice(0, 1).toLocaleUpperCase()} - + {weekDays.map(({ letter, number }) => ( +
+ {letter.toLocaleUpperCase()}
))}
- {days.map((annotatedDate) => { - if (annotatedDate.isDisplayed) { + {weeks.flatMap((week, weekIndex) => { + return weekDays.map((weekDay, dayIndex) => { + const { date, isOutOfRange } = week[weekDay.number] || {}; + const key = `${weekIndex}-${dayIndex}`; + const isToday = !isOutOfRange && date && isSameDay(date, new Date()); + const isSelected = date && value && isSameDay(value, date); + return ( -
- +
+ {date && ( + + )}
); - } - return
; + }); })}
diff --git a/packages/lumx-react/src/components/date-picker/DatePickerField.stories.tsx b/packages/lumx-react/src/components/date-picker/DatePickerField.stories.tsx index c6e7051b4..4b68c1b8e 100644 --- a/packages/lumx-react/src/components/date-picker/DatePickerField.stories.tsx +++ b/packages/lumx-react/src/components/date-picker/DatePickerField.stories.tsx @@ -8,7 +8,6 @@ export default { component: DatePickerField, args: { ...DatePickerField.defaultProps, - locale: 'fr', 'nextButtonProps.label': 'Next month', 'previousButtonProps.label': 'Previous month', }, diff --git a/packages/lumx-react/src/components/date-picker/DatePickerField.tsx b/packages/lumx-react/src/components/date-picker/DatePickerField.tsx index c0e25c5ad..5e6822b31 100644 --- a/packages/lumx-react/src/components/date-picker/DatePickerField.tsx +++ b/packages/lumx-react/src/components/date-picker/DatePickerField.tsx @@ -1,12 +1,10 @@ -import { DatePicker, Placement, Popover, TextField, IconButtonProps } from '@lumx/react'; -import { useFocusTrap } from '@lumx/react/hooks/useFocusTrap'; - -import moment from 'moment'; - import React, { forwardRef, SyntheticEvent, useCallback, useRef, useState } from 'react'; +import { DatePicker, IconButtonProps, Placement, Popover, TextField } from '@lumx/react'; +import { useFocusTrap } from '@lumx/react/hooks/useFocusTrap'; import { useFocus } from '@lumx/react/hooks/useFocus'; import { Comp, GenericProps } from '@lumx/react/utils/type'; +import { getCurrentLocale } from '@lumx/react/utils/locale/getCurrentLocale'; /** * Defines the props of the component. @@ -17,7 +15,7 @@ export interface DatePickerFieldProps extends GenericProps { /** Whether the component is disabled or not. */ isDisabled?: boolean; /** Locale (language or region) to use. */ - locale: string; + locale?: string; /** Date after which dates can't be selected. */ maxDate?: Date; /** Date before which dates can't be selected. */ @@ -52,7 +50,7 @@ export const DatePickerField: Comp = forwa defaultMonth, disabled, isDisabled = disabled, - locale, + locale = getCurrentLocale(), maxDate, minDate, name, @@ -97,6 +95,9 @@ export const DatePickerField: Comp = forwa onClose(); }; + // Format date for text field + const textFieldValue = value?.toLocaleDateString(locale, { year: 'numeric', month: 'long', day: 'numeric' }) || ''; + return ( <> = forwa name={name} forceFocusStyle={isOpen} textFieldRef={anchorRef} - value={value ? moment(value).locale(locale).format('LL') : ''} + value={textFieldValue} onClick={toggleSimpleMenu} onChange={onTextFieldChange} onKeyPress={handleKeyboardNav} diff --git a/packages/lumx-react/src/components/date-picker/types.ts b/packages/lumx-react/src/components/date-picker/types.ts index 7b10330f0..ad710fbd6 100644 --- a/packages/lumx-react/src/components/date-picker/types.ts +++ b/packages/lumx-react/src/components/date-picker/types.ts @@ -9,7 +9,7 @@ export interface DatePickerProps extends GenericProps { /** Default month. */ defaultMonth?: Date; /** Locale (language or region) to use. */ - locale: string; + locale?: string; /** Date after which dates can't be selected. */ maxDate?: Date; /** Date before which dates can't be selected. */ diff --git a/packages/lumx-react/src/utils/date/addMonthResetDay.test.ts b/packages/lumx-react/src/utils/date/addMonthResetDay.test.ts new file mode 100644 index 000000000..74ce0f2de --- /dev/null +++ b/packages/lumx-react/src/utils/date/addMonthResetDay.test.ts @@ -0,0 +1,13 @@ +import { addMonthResetDay } from '@lumx/react/utils/date/addMonthResetDay'; + +describe(addMonthResetDay.name, () => { + it('should add month to date', () => { + const actual = addMonthResetDay(new Date('2017-01-30'), 1); + expect(actual).toEqual(new Date('2017-02-01')); + }); + + it('should remove months to date', () => { + const actual = addMonthResetDay(new Date('2017-01-30'), -2); + expect(actual).toEqual(new Date('2016-11-01')); + }); +}); diff --git a/packages/lumx-react/src/utils/date/addMonthResetDay.ts b/packages/lumx-react/src/utils/date/addMonthResetDay.ts new file mode 100644 index 000000000..7c99ca268 --- /dev/null +++ b/packages/lumx-react/src/utils/date/addMonthResetDay.ts @@ -0,0 +1,9 @@ +/** + * Add a number of months from a date while resetting the day to prevent month length mismatches. + */ +export function addMonthResetDay(date: Date, monthOffset: number) { + const newDate = new Date(date.getTime()); + newDate.setDate(1); + newDate.setMonth(date.getMonth() + monthOffset); + return newDate; +} diff --git a/packages/lumx-react/src/utils/date/getFirstDayOfWeek.test.ts b/packages/lumx-react/src/utils/date/getFirstDayOfWeek.test.ts new file mode 100644 index 000000000..b3b0b9312 --- /dev/null +++ b/packages/lumx-react/src/utils/date/getFirstDayOfWeek.test.ts @@ -0,0 +1,20 @@ +import { Locale } from '@lumx/react/utils/locale/types'; +import { parseLocale } from '../locale/parseLocale'; +import { getFirstDayOfWeek } from './getFirstDayOfWeek'; + +describe(getFirstDayOfWeek.name, () => { + it('should return for a valid locales', () => { + expect(getFirstDayOfWeek(parseLocale('fa-ir') as Locale)).toBe(6); + expect(getFirstDayOfWeek(parseLocale('ar-ma') as Locale)).toBe(1); + expect(getFirstDayOfWeek(parseLocale('ar') as Locale)).toBe(6); + expect(getFirstDayOfWeek(parseLocale('ar-eg') as Locale)).toBe(0); + }); + + it('should return for the lang locale if available', () => { + // Test for a specific locale and its root locale + const localeWithRoot = parseLocale('es-ES') as Locale; // Spanish (Spain) with root locale es + const expectedFirstDay = getFirstDayOfWeek(parseLocale('es') as Locale); // First day for root locale 'es' + + expect(getFirstDayOfWeek(localeWithRoot)).toBe(expectedFirstDay); + }); +}); diff --git a/packages/lumx-react/src/utils/date/getFirstDayOfWeek.ts b/packages/lumx-react/src/utils/date/getFirstDayOfWeek.ts new file mode 100644 index 000000000..c2034231c --- /dev/null +++ b/packages/lumx-react/src/utils/date/getFirstDayOfWeek.ts @@ -0,0 +1,58 @@ +import { Locale } from '@lumx/react/utils/locale/types'; + +/** Get first day of week for locale from the browser API */ +export const getFromBrowser = (locale: Locale): number | undefined => { + try { + const localeMetadata = new Intl.Locale(locale.code) as any; + const { firstDay } = localeMetadata.getWeekInfo?.() || localeMetadata.weekInfo; + // Sunday is represented as `0` in Date.getDay() + if (firstDay === 7) return 0; + return firstDay; + } catch (e) { + return undefined; + } +}; + +/** List first day for each locale (could be removed when all browser implement Locale weekInfo) */ +const FIRST_DAY_FOR_LOCALES = [ + { + // Locales with Sunday as the first day of the week + localeRX: /^(af|ar-(dz|eg|sa)|bn|cy|en-(ca|us|za)|fr-ca|gd|he|hi|ja|km|ko|pt-br|te|th|ug|zh-hk)$/i, + firstDay: 0, + }, + { + // Locales with Monday as the first day of the week + localeRX: /^(ar-(ma|tn)|az|be|bg|bs|ca|cs|da|de|el|en-(au|gb|ie|in|nz)|eo|es|et|eu|fi|fr|fy|gl|gu|hr|ht|hu|hy|id|is|it|ka|kk|kn|lb|lt|lv|mk|mn|ms|mt|nb|nl|nn|oc|pl|pt|ro|ru|sk|sl|sq|sr|sv|ta|tr|uk|uz|vi|zh-(cn|tw))$/i, + firstDay: 1, + }, + { + // Locales with Saturday as the first day of the week + localeRX: /^(ar|fa-ir)$/i, + firstDay: 6, + }, +]; + +/** Find first day of week for locale from the constant */ +const getFromConstant = (locale: Locale, searchBy: keyof Locale = 'code'): number | undefined => { + // Search for locale (lang + region) + for (const { localeRX, firstDay } of FIRST_DAY_FOR_LOCALES) { + if (localeRX.test(locale[searchBy] as string)) return firstDay; + } + // Fallback search for locale lang + if (locale.code !== locale.language) { + return getFromConstant(locale, 'language'); + } + return undefined; +}; + +/** + * Get first day of the week for the given locale code (language + region). + */ +export const getFirstDayOfWeek = (locale: Locale): number | undefined => { + // Get from browser API + const firstDay = getFromBrowser(locale); + if (firstDay !== undefined) return firstDay; + + // Get from constant + return getFromConstant(locale); +}; diff --git a/packages/lumx-react/src/utils/date/getMonthCalendar.test.ts b/packages/lumx-react/src/utils/date/getMonthCalendar.test.ts new file mode 100644 index 000000000..ffb54339a --- /dev/null +++ b/packages/lumx-react/src/utils/date/getMonthCalendar.test.ts @@ -0,0 +1,123 @@ +import { parseLocale } from '@lumx/react/utils/locale/parseLocale'; +import { Locale } from '@lumx/react/utils/locale/types'; +import { getMonthCalendar } from './getMonthCalendar'; + +describe(getMonthCalendar.name, () => { + it('should generate calendar', () => { + const referenceDate = new Date('2017-02-03'); + const french = parseLocale('fr') as Locale; + const month = getMonthCalendar(french, referenceDate); + + expect(month).toEqual({ + weekDays: [ + { letter: 'L', number: 1 }, + { letter: 'M', number: 2 }, + { letter: 'M', number: 3 }, + { letter: 'J', number: 4 }, + { letter: 'V', number: 5 }, + { letter: 'S', number: 6 }, + { letter: 'D', number: 0 }, + ], + weeks: [ + { + '3': { date: new Date('2017-02-01') }, + '4': { date: new Date('2017-02-02') }, + '5': { date: new Date('2017-02-03') }, + '6': { date: new Date('2017-02-04') }, + '0': { date: new Date('2017-02-05') }, + }, + { + '1': { date: new Date('2017-02-06') }, + '2': { date: new Date('2017-02-07') }, + '3': { date: new Date('2017-02-08') }, + '4': { date: new Date('2017-02-09') }, + '5': { date: new Date('2017-02-10') }, + '6': { date: new Date('2017-02-11') }, + '0': { date: new Date('2017-02-12') }, + }, + { + '1': { date: new Date('2017-02-13') }, + '2': { date: new Date('2017-02-14') }, + '3': { date: new Date('2017-02-15') }, + '4': { date: new Date('2017-02-16') }, + '5': { date: new Date('2017-02-17') }, + '6': { date: new Date('2017-02-18') }, + '0': { date: new Date('2017-02-19') }, + }, + { + '1': { date: new Date('2017-02-20') }, + '2': { date: new Date('2017-02-21') }, + '3': { date: new Date('2017-02-22') }, + '4': { date: new Date('2017-02-23') }, + '5': { date: new Date('2017-02-24') }, + '6': { date: new Date('2017-02-25') }, + '0': { date: new Date('2017-02-26') }, + }, + { + '1': { date: new Date('2017-02-27') }, + '2': { date: new Date('2017-02-28') }, + }, + ], + }); + }); + + it('should generate calendar with sunday as start of week and mark dates in range', () => { + const referenceDate = new Date('2017-02-03'); + const minDate = new Date('2017-02-06'); + const maxDate = new Date('2017-02-10'); + const englishUS = parseLocale('en-US') as Locale; + const month = getMonthCalendar(englishUS, referenceDate, minDate, maxDate); + + expect(month).toEqual({ + weekDays: [ + { letter: 'S', number: 0 }, + { letter: 'M', number: 1 }, + { letter: 'T', number: 2 }, + { letter: 'W', number: 3 }, + { letter: 'T', number: 4 }, + { letter: 'F', number: 5 }, + { letter: 'S', number: 6 }, + ], + weeks: [ + { + '3': { date: new Date('2017-02-01'), isOutOfRange: true }, + '4': { date: new Date('2017-02-02'), isOutOfRange: true }, + '5': { date: new Date('2017-02-03'), isOutOfRange: true }, + '6': { date: new Date('2017-02-04'), isOutOfRange: true }, + }, + { + '0': { date: new Date('2017-02-05'), isOutOfRange: true }, + '1': { date: new Date('2017-02-06'), isOutOfRange: true }, + '2': { date: new Date('2017-02-07') }, + '3': { date: new Date('2017-02-08') }, + '4': { date: new Date('2017-02-09') }, + '5': { date: new Date('2017-02-10'), isOutOfRange: true }, + '6': { date: new Date('2017-02-11'), isOutOfRange: true }, + }, + { + '0': { date: new Date('2017-02-12'), isOutOfRange: true }, + '1': { date: new Date('2017-02-13'), isOutOfRange: true }, + '2': { date: new Date('2017-02-14'), isOutOfRange: true }, + '3': { date: new Date('2017-02-15'), isOutOfRange: true }, + '4': { date: new Date('2017-02-16'), isOutOfRange: true }, + '5': { date: new Date('2017-02-17'), isOutOfRange: true }, + '6': { date: new Date('2017-02-18'), isOutOfRange: true }, + }, + { + '0': { date: new Date('2017-02-19'), isOutOfRange: true }, + '1': { date: new Date('2017-02-20'), isOutOfRange: true }, + '2': { date: new Date('2017-02-21'), isOutOfRange: true }, + '3': { date: new Date('2017-02-22'), isOutOfRange: true }, + '4': { date: new Date('2017-02-23'), isOutOfRange: true }, + '5': { date: new Date('2017-02-24'), isOutOfRange: true }, + '6': { date: new Date('2017-02-25'), isOutOfRange: true }, + }, + { + '0': { date: new Date('2017-02-26'), isOutOfRange: true }, + '1': { date: new Date('2017-02-27'), isOutOfRange: true }, + '2': { date: new Date('2017-02-28'), isOutOfRange: true }, + }, + ], + }); + }); +}); diff --git a/packages/lumx-react/src/utils/date/getMonthCalendar.ts b/packages/lumx-react/src/utils/date/getMonthCalendar.ts new file mode 100644 index 000000000..2d998a272 --- /dev/null +++ b/packages/lumx-react/src/utils/date/getMonthCalendar.ts @@ -0,0 +1,52 @@ +import last from 'lodash/last'; + +import { getWeekDays, WeekDayInfo } from '@lumx/react/utils/date/getWeekDays'; +import { Locale } from '@lumx/react/utils/locale/types'; + +type AnnotatedDay = { date: Date; isOutOfRange?: boolean }; +type AnnotatedWeek = Partial>; + +interface MonthCalendar { + weekDays: Array; + weeks: Array; +} + +/** + * Get month calendar. + * A list of weeks with days indexed by week day number + */ +export const getMonthCalendar = ( + locale: Locale, + referenceDate = new Date(), + rangeMinDate?: Date, + rangeMaxDate?: Date, +): MonthCalendar => { + const month = referenceDate.getMonth(); + const iterDate = new Date(referenceDate.getTime()); + iterDate.setDate(1); + + const weekDays = getWeekDays(locale); + const lastDayOfWeek = last(weekDays) as WeekDayInfo; + + const weeks: Array = []; + let week: AnnotatedWeek = {}; + while (iterDate.getMonth() === month) { + const weekDayNumber = iterDate.getDay(); + const day: AnnotatedDay = { date: new Date(iterDate.getTime()) }; + + // If a range is specified, check if the day is out of range. + if ((rangeMinDate && iterDate <= rangeMinDate) || (rangeMaxDate && iterDate >= rangeMaxDate)) { + day.isOutOfRange = true; + } + + week[weekDayNumber] = day; + if (weekDayNumber === lastDayOfWeek.number) { + weeks.push(week); + week = {}; + } + iterDate.setDate(iterDate.getDate() + 1); + } + if (Object.keys(week).length) weeks.push(week); + + return { weeks, weekDays }; +}; diff --git a/packages/lumx-react/src/utils/date/getWeekDays.test.ts b/packages/lumx-react/src/utils/date/getWeekDays.test.ts new file mode 100644 index 000000000..b54271f2a --- /dev/null +++ b/packages/lumx-react/src/utils/date/getWeekDays.test.ts @@ -0,0 +1,48 @@ +import { parseLocale } from '@lumx/react/utils/locale/parseLocale'; +import { Locale } from '@lumx/react/utils/locale/types'; +import { getWeekDays } from './getWeekDays'; + +describe(getWeekDays.name, () => { + const french = parseLocale('fr') as Locale; + const englishUS = parseLocale('en-us') as Locale; + const farsi = parseLocale('fa-ir') as Locale; + + it('should list french week days', () => { + const weekDays = getWeekDays(french); + expect(weekDays).toEqual([ + { letter: 'L', number: 1 }, + { letter: 'M', number: 2 }, + { letter: 'M', number: 3 }, + { letter: 'J', number: 4 }, + { letter: 'V', number: 5 }, + { letter: 'S', number: 6 }, + { letter: 'D', number: 0 }, + ]); + }); + + it('should list US week days', () => { + const weekDays = getWeekDays(englishUS); + expect(weekDays).toEqual([ + { letter: 'S', number: 0 }, + { letter: 'M', number: 1 }, + { letter: 'T', number: 2 }, + { letter: 'W', number: 3 }, + { letter: 'T', number: 4 }, + { letter: 'F', number: 5 }, + { letter: 'S', number: 6 }, + ]); + }); + + it('should list fa-ir week days', () => { + const weekDays = getWeekDays(farsi); + expect(weekDays).toEqual([ + { letter: 'ش', number: 6 }, + { letter: 'ی', number: 0 }, + { letter: 'د', number: 1 }, + { letter: 'س', number: 2 }, + { letter: 'چ', number: 3 }, + { letter: 'پ', number: 4 }, + { letter: 'ج', number: 5 }, + ]); + }); +}); diff --git a/packages/lumx-react/src/utils/date/getWeekDays.ts b/packages/lumx-react/src/utils/date/getWeekDays.ts new file mode 100644 index 000000000..8246130a3 --- /dev/null +++ b/packages/lumx-react/src/utils/date/getWeekDays.ts @@ -0,0 +1,32 @@ +import { Locale } from '@lumx/react/utils/locale/types'; +import { getFirstDayOfWeek } from './getFirstDayOfWeek'; + +export type WeekDayInfo = { letter: string; number: number }; + +export const DAYS_PER_WEEK = 7; + +/** + * List week days (based on locale) with the week day letter (ex: "M" for "Monday") and week day number + * (0-based index starting on Sunday). + */ +export const getWeekDays = (locale: Locale): Array => { + const iterDate = new Date(); + const firstDay = getFirstDayOfWeek(locale) ?? 1; + + // Go to start of the week + const offset = firstDay - iterDate.getDay(); + iterDate.setDate(iterDate.getDate() + offset); + + // Iterate through the days of the week + const weekDays: Array = []; + for (let i = 0; i < DAYS_PER_WEEK; i++) { + // Single letter week day (ex: "M" for "Monday", "L" for "Lundi", etc.) + const letter = iterDate.toLocaleDateString(locale.code, { weekday: 'narrow' }); + // Day number (1-based index starting on Monday) + const number = iterDate.getDay(); + + weekDays.push({ letter, number }); + iterDate.setDate(iterDate.getDate() + 1); + } + return weekDays; +}; diff --git a/packages/lumx-react/src/utils/date/isDateValid.test.ts b/packages/lumx-react/src/utils/date/isDateValid.test.ts new file mode 100644 index 000000000..0ffdb20e8 --- /dev/null +++ b/packages/lumx-react/src/utils/date/isDateValid.test.ts @@ -0,0 +1,15 @@ +import { isDateValid } from '@lumx/react/utils/date/isDateValid'; + +describe(isDateValid.name, () => { + it('should mark `undefined` as invalid', () => { + expect(isDateValid(undefined)).toBe(false); + }); + + it('should mark invalid date as invalid', () => { + expect(isDateValid(new Date('foo'))).toBe(false); + }); + + it('should mark valid date as valid', () => { + expect(isDateValid(new Date())).toBe(true); + }); +}); diff --git a/packages/lumx-react/src/utils/date/isDateValid.ts b/packages/lumx-react/src/utils/date/isDateValid.ts new file mode 100644 index 000000000..d111e9e36 --- /dev/null +++ b/packages/lumx-react/src/utils/date/isDateValid.ts @@ -0,0 +1,4 @@ +/** + * Check if given date is valid. + */ +export const isDateValid = (date?: Date) => date instanceof Date && !Number.isNaN(date.getTime()); diff --git a/packages/lumx-react/src/utils/date/isSameDay.test.ts b/packages/lumx-react/src/utils/date/isSameDay.test.ts new file mode 100644 index 000000000..da6d5106f --- /dev/null +++ b/packages/lumx-react/src/utils/date/isSameDay.test.ts @@ -0,0 +1,37 @@ +import { isSameDay } from '@lumx/react/utils/date/isSameDay'; + +describe(isSameDay, () => { + it('should return true for same dates', () => { + const date1 = new Date('2023-08-26T12:00:00'); + const date2 = new Date('2023-08-26T15:30:00'); + expect(isSameDay(date1, date2)).toBe(true); + }); + + it('should return false for different dates', () => { + const date1 = new Date('2023-08-26T10:00:00'); + const date2 = new Date('2023-08-27T10:00:00'); + expect(isSameDay(date1, date2)).toBe(false); + }); + + it('should handle different months', () => { + const date1 = new Date('2023-08-15T08:00:00'); + const date2 = new Date('2023-09-15T08:00:00'); + expect(isSameDay(date1, date2)).toBe(false); + }); + + it('should handle different years', () => { + const date1 = new Date('2022-12-25T18:30:00'); + const date2 = new Date('1923-12-25T18:30:00'); + expect(isSameDay(date1, date2)).toBe(false); + }); + + it('should handle invalid date', () => { + // Invalid date input are not comparable, so we always return `false` + // Undefined date + expect(isSameDay(undefined as any, undefined as any)).toBe(false); + // Null date + expect(isSameDay(null as any, new Date())).toBe(false); + // Invalid date + expect(isSameDay(new Date('-'), new Date('-'))).toBe(false); + }); +}); diff --git a/packages/lumx-react/src/utils/date/isSameDay.ts b/packages/lumx-react/src/utils/date/isSameDay.ts new file mode 100644 index 000000000..fe294b212 --- /dev/null +++ b/packages/lumx-react/src/utils/date/isSameDay.ts @@ -0,0 +1,11 @@ +import { isDateValid } from '@lumx/react/utils/date/isDateValid'; + +/** + * Check `date1` is on the same day as `date2`. + */ +export const isSameDay = (date1: Date, date2: Date) => + isDateValid(date1) && + isDateValid(date2) && + date1.getFullYear() === date2.getFullYear() && + date1.getMonth() === date2.getMonth() && + date1.getDate() === date2.getDate(); diff --git a/packages/lumx-react/src/utils/locale/getCurrentLocale.ts b/packages/lumx-react/src/utils/locale/getCurrentLocale.ts new file mode 100644 index 000000000..5fb129ec8 --- /dev/null +++ b/packages/lumx-react/src/utils/locale/getCurrentLocale.ts @@ -0,0 +1,4 @@ +/** + * Get current browser locale. + */ +export const getCurrentLocale = (): string => navigator.languages?.[0] || navigator.language; diff --git a/packages/lumx-react/src/utils/locale/parseLocale.test.ts b/packages/lumx-react/src/utils/locale/parseLocale.test.ts new file mode 100644 index 000000000..3976e3916 --- /dev/null +++ b/packages/lumx-react/src/utils/locale/parseLocale.test.ts @@ -0,0 +1,17 @@ +import { parseLocale } from '@lumx/react/utils/locale/parseLocale'; + +describe(parseLocale.name, () => { + it('should parse various locale formats', () => { + expect(parseLocale('en')).toEqual({ code: 'en', language: 'en' }); + expect(parseLocale('EN')).toEqual({ code: 'en', language: 'en' }); + expect(parseLocale('en-US')).toEqual({ code: 'en-US', language: 'en', region: 'US' }); + expect(parseLocale('en-us')).toEqual({ code: 'en-US', language: 'en', region: 'US' }); + expect(parseLocale('en_us')).toEqual({ code: 'en-US', language: 'en', region: 'US' }); + expect(parseLocale('EN-US')).toEqual({ code: 'en-US', language: 'en', region: 'US' }); + }); + + it('should fail on invalid locale', () => { + expect(parseLocale('-')).toBe(undefined); + expect(parseLocale('-foo')).toBe(undefined); + }); +}); diff --git a/packages/lumx-react/src/utils/locale/parseLocale.ts b/packages/lumx-react/src/utils/locale/parseLocale.ts new file mode 100644 index 000000000..be7e2e16c --- /dev/null +++ b/packages/lumx-react/src/utils/locale/parseLocale.ts @@ -0,0 +1,23 @@ +import { Locale } from '@lumx/react/utils/locale/types'; + +/** + * Parse locale code + * @example + * parseLocale('EN') // => { code: 'en', language: 'en' } + * parseLocale('en_us') // => { code: 'en-US', language: 'en', region: 'US' } + * parseLocale('EN-US') // => { code: 'en-US', language: 'en', region: 'US' } + */ +export function parseLocale(locale: string): Locale | undefined { + const [rawLanguage, rawRegion] = locale.split(/[-_]/); + if (!rawLanguage) { + return undefined; + } + const language = rawLanguage.toLowerCase(); + let region: string | undefined; + let code = language; + if (rawRegion) { + region = rawRegion.toUpperCase(); + code += `-${region}`; + } + return { code, region, language }; +} diff --git a/packages/lumx-react/src/utils/locale/types.ts b/packages/lumx-react/src/utils/locale/types.ts new file mode 100644 index 000000000..2b6763241 --- /dev/null +++ b/packages/lumx-react/src/utils/locale/types.ts @@ -0,0 +1,8 @@ +export interface Locale { + /** ISO locale code `lang-REGION` (ex: `en-US`) */ + code: string; + /** ISO locale language code (ex: `en`) */ + language: string; + /** ISO locale region code (ex: `US`) */ + region?: string; +} diff --git a/yarn.lock b/yarn.lock index 2e5075d9d..2add57d12 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6229,8 +6229,6 @@ __metadata: yarn: ^1.19.1 peerDependencies: lodash: 4.17.21 - moment: ">= 2" - moment-range: ^4.0.2 react: ">= 16.13.0" react-dom: ">= 16.13.0" languageName: unknown