-
Notifications
You must be signed in to change notification settings - Fork 2.9k
feat(react-calendar-preview): add date utilities and formatters #36746
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Dmytro Kirpa (dmytrokirpa)
wants to merge
11
commits into
master
from
calendar-preview/date-utilities
+2,490
−0
Open
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
e7e10e5
feat(react-calendar-preview): add date utilities and formatters
dmytrokirpa 0e61869
fix(react-calendar-preview): address date utility review feedback
dmytrokirpa 6ea7af0
refactor(react-calendar-preview): separate date utilities from constants
dmytrokirpa b56b139
chore(react-calendar-preview): revert utility coverage docs config
Copilot 8ce9b3f
fix(calendar-preview): address review feedback
dmytrokirpa 819c1af
Merge remote-tracking branch 'upstream/calendar-preview/date-utilitie…
dmytrokirpa f7a218f
fix(calendar-preview): handle year boundaries
dmytrokirpa 78a896a
fix(calendar-preview): validate utility contracts
dmytrokirpa e97f55f
fix(calendar-preview): advance week ranges across months
dmytrokirpa 0b6053a
fix(calendar-preview): isolate focus scheduling
dmytrokirpa e4d4c53
fix(calendar-preview): preserve skipped date grid positions
dmytrokirpa File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
75 changes: 75 additions & 0 deletions
75
packages/react-components/react-calendar-preview/library/src/utils/constants.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| /** | ||
| * The days of the week. | ||
| */ | ||
| export type DayOfWeek = 'sunday' | 'monday' | 'tuesday' | 'wednesday' | 'thursday' | 'friday' | 'saturday'; | ||
|
|
||
| /** | ||
| * The days of the week, ordered so that each index matches `Date.prototype.getDay()`. | ||
| */ | ||
| export const daysOfWeek = [ | ||
| 'sunday', | ||
| 'monday', | ||
| 'tuesday', | ||
| 'wednesday', | ||
| 'thursday', | ||
| 'friday', | ||
| 'saturday', | ||
| ] as const satisfies readonly DayOfWeek[]; | ||
|
|
||
| /** | ||
| * The months of the year. | ||
| */ | ||
| export type MonthOfYear = | ||
| | 'january' | ||
| | 'february' | ||
| | 'march' | ||
| | 'april' | ||
| | 'may' | ||
| | 'june' | ||
| | 'july' | ||
| | 'august' | ||
| | 'september' | ||
| | 'october' | ||
| | 'november' | ||
| | 'december'; | ||
|
|
||
| /** | ||
| * The months of the year, ordered so that each index matches `Date.prototype.getMonth()`. | ||
| */ | ||
| export const monthsOfYear = [ | ||
| 'january', | ||
| 'february', | ||
| 'march', | ||
| 'april', | ||
| 'may', | ||
| 'june', | ||
| 'july', | ||
| 'august', | ||
| 'september', | ||
| 'october', | ||
| 'november', | ||
| 'december', | ||
| ] as const satisfies readonly MonthOfYear[]; | ||
|
|
||
| /** | ||
| * Determines which week counts as the first week of the year. | ||
| * - `firstDay` - the week containing January 1st. | ||
| * - `firstFullWeek` - the first week entirely within the new year. | ||
| * - `firstFourDayWeek` - the first week with at least four days in the new year. | ||
| */ | ||
| export type FirstWeekOfYear = 'firstDay' | 'firstFullWeek' | 'firstFourDayWeek'; | ||
|
|
||
| /** | ||
| * The supported date range types, describing how many days are selected when the user picks a date. | ||
| */ | ||
| export type DateRangeType = 'day' | 'week' | 'month' | 'workWeek'; | ||
|
|
||
| /** | ||
| * The axis along which the day grid transitions when navigating between months. | ||
| */ | ||
| export type AnimationDirection = 'horizontal' | 'vertical'; | ||
|
|
||
| /** | ||
| * Number of days in a week. | ||
| */ | ||
| export const DAYS_IN_WEEK = 7; |
11 changes: 11 additions & 0 deletions
11
packages/react-components/react-calendar-preview/library/src/utils/dataAttributes.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| import { stringifyDataAttribute } from './dataAttributes'; | ||
|
|
||
| describe('stringifyDataAttribute', () => { | ||
| it.each([ | ||
| [true, ''], | ||
| [false, undefined], | ||
| [undefined, undefined], | ||
| ] as const)('serializes %s as %s', (value, expected) => { | ||
| expect(stringifyDataAttribute(value)).toBe(expected); | ||
| }); | ||
| }); |
7 changes: 7 additions & 0 deletions
7
packages/react-components/react-calendar-preview/library/src/utils/dataAttributes.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| /** | ||
| * Renders a boolean as a presence data attribute: `''` when true, `undefined` when false so the | ||
| * attribute is omitted entirely. | ||
| */ | ||
| export function stringifyDataAttribute(value: boolean | undefined): '' | undefined { | ||
| return value ? '' : undefined; | ||
| } |
144 changes: 144 additions & 0 deletions
144
...act-components/react-calendar-preview/library/src/utils/dateGrid/dateAvailability.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,144 @@ | ||
| import { findAvailableDate, getBoundedDateRange, isRestrictedDate } from './dateAvailability'; | ||
| import * as dateMath from '../dateMath'; | ||
| import type { AvailableDateOptions } from './dateGrid.types'; | ||
|
|
||
| jest.mock('../dateMath', () => ({ | ||
| __esModule: true, | ||
| ...jest.requireActual<typeof import('../dateMath')>('../dateMath'), | ||
| })); | ||
|
|
||
| describe('isRestrictedDate', () => { | ||
| const date = new Date(2020, 8, 18); | ||
|
|
||
| it('allows unrestricted dates and empty restrictions', () => { | ||
| expect(isRestrictedDate(date, {})).toBe(false); | ||
| expect(isRestrictedDate(date, { restrictedDates: [] })).toBe(false); | ||
| }); | ||
|
|
||
| it('uses inclusive min/max bounds ignoring time of day', () => { | ||
| const options = { minDate: new Date(2020, 8, 18, 12), maxDate: new Date(2020, 8, 20) }; | ||
| expect(isRestrictedDate(date, options)).toBe(false); | ||
| expect(isRestrictedDate(new Date(2020, 8, 20, 23), options)).toBe(false); | ||
| expect(isRestrictedDate(new Date(2020, 8, 17), options)).toBe(true); | ||
| expect(isRestrictedDate(new Date(2020, 8, 21), options)).toBe(true); | ||
| }); | ||
|
|
||
| it('matches restricted dates by year, month and day, not time', () => { | ||
| expect(isRestrictedDate(date, { restrictedDates: [new Date(2020, 8, 18, 23)] })).toBe(true); | ||
| expect(isRestrictedDate(date, { restrictedDates: [new Date(2020, 7, 18), new Date(2019, 8, 18)] })).toBe(false); | ||
| }); | ||
| }); | ||
|
|
||
| describe('findAvailableDate', () => { | ||
| afterEach(() => jest.restoreAllMocks()); | ||
|
|
||
| const targetDate = new Date(2020, 8, 18); | ||
| const options: AvailableDateOptions = { targetDate, initialDate: new Date(2020, 8, 15), direction: 1 }; | ||
|
|
||
| it('returns the available target unchanged, including the initial date', () => { | ||
| expect(findAvailableDate(options)).toBe(targetDate); | ||
| expect(findAvailableDate({ ...options, initialDate: targetDate })).toBe(targetDate); | ||
| }); | ||
|
|
||
| it.each([1, -1] as const)('skips consecutive restricted dates in direction %s', direction => { | ||
| expect( | ||
| findAvailableDate({ | ||
| ...options, | ||
| direction, | ||
| restrictedDates: [targetDate, new Date(2020, 8, 18 + direction)], | ||
| }), | ||
| ).toEqual(new Date(2020, 8, 18 + 2 * direction)); | ||
| expect(targetDate).toEqual(new Date(2020, 8, 18)); | ||
| }); | ||
|
|
||
| it.each([1, -1] as const)('stops when reaching the initial date in direction %s', direction => { | ||
| expect( | ||
| findAvailableDate({ | ||
| ...options, | ||
| direction, | ||
| initialDate: new Date(2020, 8, 18 + direction), | ||
| restrictedDates: [targetDate], | ||
| }), | ||
| ).toBeUndefined(); | ||
| }); | ||
|
|
||
| it('does not search beyond the min/max date', () => { | ||
| expect(findAvailableDate({ ...options, restrictedDates: [targetDate], maxDate: targetDate })).toBeUndefined(); | ||
| expect( | ||
| findAvailableDate({ ...options, direction: -1, restrictedDates: [targetDate], minDate: targetDate }), | ||
| ).toBeUndefined(); | ||
| expect(findAvailableDate({ ...options, minDate: new Date(2020, 8, 19) })).toBeUndefined(); | ||
| expect(findAvailableDate({ ...options, maxDate: new Date(2020, 8, 17) })).toBeUndefined(); | ||
| }); | ||
|
|
||
| it('returns undefined when the initial date is itself restricted', () => { | ||
| expect(findAvailableDate({ ...options, initialDate: targetDate, restrictedDates: [targetDate] })).toBeUndefined(); | ||
| }); | ||
|
|
||
| it.each([0, 2, -2, 0.5, NaN, Infinity, -Infinity])('rejects direction %s even for an available target', direction => { | ||
| expect(() => | ||
| // @ts-expect-error Verify the runtime contract for JavaScript callers. | ||
| findAvailableDate({ ...options, direction }), | ||
| ).toThrow(new RangeError('direction must be 1 or -1.')); | ||
| }); | ||
|
|
||
| it.each(['targetDate', 'initialDate'] as const)('rejects an invalid %s', field => { | ||
| expect(() => findAvailableDate({ ...options, [field]: new Date(NaN) })).toThrow( | ||
| new RangeError('targetDate and initialDate must be valid.'), | ||
| ); | ||
| }); | ||
|
|
||
| it('rejects a search that exceeds the Date range', () => { | ||
| const lastDate = new Date(8640000000000000); | ||
| expect(() => findAvailableDate({ ...options, targetDate: lastDate, restrictedDates: [lastDate] })).toThrow( | ||
| new RangeError('Cannot search for an out-of-range date.'), | ||
| ); | ||
| }); | ||
|
|
||
| it('advances independently when a skipped day normalizes to the previous candidate', () => { | ||
| const addDays = dateMath.addDays; | ||
| const offset = jest.spyOn(dateMath, 'addDays').mockImplementation((date, days) => { | ||
| return days === -1 ? new Date(date) : addDays(date, days); | ||
| }); | ||
| expect(findAvailableDate({ ...options, direction: -1, restrictedDates: [targetDate] })).toEqual( | ||
| new Date(2020, 8, 16), | ||
| ); | ||
| expect(offset.mock.calls.map(([, days]) => days)).toEqual([-1, -2]); | ||
| }); | ||
|
|
||
| it('searches backward across December 30, 2011 using the runtime timezone', () => { | ||
| const date = new Date(2011, 11, 31); | ||
| const skippedFriday = new Date(2011, 11, 30).getDate() !== 30; | ||
| expect( | ||
| findAvailableDate({ | ||
| targetDate: date, | ||
| initialDate: new Date(2012, 0, 1), | ||
| direction: -1, | ||
| restrictedDates: [date], | ||
| }), | ||
| ).toEqual(new Date(2011, 11, skippedFriday ? 29 : 30)); | ||
| }); | ||
| }); | ||
|
|
||
| describe('getBoundedDateRange', () => { | ||
| const dates = [new Date(2020, 8, 17), new Date(2020, 8, 18), new Date(2020, 8, 19)]; | ||
|
|
||
| it('returns a copy when there are no bounds', () => { | ||
| const result = getBoundedDateRange(dates); | ||
| expect(result).toEqual(dates); | ||
| expect(result).not.toBe(dates); | ||
| }); | ||
|
|
||
| it('filters inclusive min/max bounds independently and together without mutating the input', () => { | ||
| expect(getBoundedDateRange(dates, dates[1])).toEqual(dates.slice(1)); | ||
| expect(getBoundedDateRange(dates, undefined, dates[1])).toEqual(dates.slice(0, 2)); | ||
| expect(getBoundedDateRange(dates, new Date(2020, 8, 18, 12), dates[1])).toEqual([dates[1]]); | ||
| expect(dates).toHaveLength(3); | ||
| }); | ||
|
|
||
| it('returns no dates for an empty range or disjoint bounds', () => { | ||
| expect(getBoundedDateRange([], dates[0], dates[2])).toEqual([]); | ||
| expect(getBoundedDateRange(dates, new Date(2020, 8, 20))).toEqual([]); | ||
| expect(getBoundedDateRange(dates, dates[2], dates[0])).toEqual([]); | ||
| }); | ||
| }); |
91 changes: 91 additions & 0 deletions
91
...es/react-components/react-calendar-preview/library/src/utils/dateGrid/dateAvailability.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| import { addDays, compareDatePart } from '../dateMath'; | ||
| import type { AvailableDateOptions, RestrictedDatesOptions } from './dateGrid.types'; | ||
|
|
||
| /** | ||
| * Checks if a date is after the maximum allowed date based on the given restrictions. | ||
| */ | ||
| const isAfterMaxDate = (date: Date, options: RestrictedDatesOptions): boolean => { | ||
| const { maxDate } = options; | ||
| return maxDate ? compareDatePart(date, maxDate) >= 1 : false; | ||
| }; | ||
|
|
||
| /** | ||
| * Checks if a date is before the minimum allowed date based on the given restrictions. | ||
| */ | ||
| const isBeforeMinDate = (date: Date, options: RestrictedDatesOptions): boolean => { | ||
| const { minDate } = options; | ||
| return minDate ? compareDatePart(minDate, date) >= 1 : false; | ||
| }; | ||
|
|
||
| /** | ||
| * Checks if `date` falls into the restricted `options` | ||
| * @param date - date to check | ||
| * @param options - restriction options (min date, max date and list of restricted dates) | ||
| */ | ||
| export const isRestrictedDate = (date: Date, options: RestrictedDatesOptions): boolean => { | ||
| const { restrictedDates, minDate, maxDate } = options; | ||
| if (!restrictedDates && !minDate && !maxDate) { | ||
| return false; | ||
| } | ||
| const inRestrictedDates = restrictedDates && restrictedDates.some((rd: Date) => compareDatePart(rd, date) === 0); | ||
| return inRestrictedDates || isBeforeMinDate(date, options) || isAfterMaxDate(date, options); | ||
| }; | ||
|
|
||
| /** | ||
| * Returns closest available date given the restriction `options`, or undefined otherwise | ||
| * @param options - list of search options | ||
| * @throws RangeError if the direction is not 1 or -1, or search dates cannot be represented. | ||
| */ | ||
| export const findAvailableDate = (options: AvailableDateOptions): Date | undefined => { | ||
| const { targetDate, initialDate, direction, ...restrictionOptions } = options; | ||
| if (direction !== 1 && direction !== -1) { | ||
| throw new RangeError('direction must be 1 or -1.'); | ||
| } | ||
| if (!Number.isFinite(targetDate.getTime()) || !Number.isFinite(initialDate.getTime())) { | ||
| throw new RangeError('targetDate and initialDate must be valid.'); | ||
| } | ||
|
|
||
| let availableDate = targetDate; | ||
| let daysOffset = 0; | ||
| // if the target date is available, return it immediately | ||
| if (!isRestrictedDate(targetDate, restrictionOptions)) { | ||
| return targetDate; | ||
| } | ||
|
|
||
| while ( | ||
| compareDatePart(initialDate, availableDate) !== 0 && | ||
| isRestrictedDate(availableDate, restrictionOptions) && | ||
| !isAfterMaxDate(availableDate, restrictionOptions) && | ||
| !isBeforeMinDate(availableDate, restrictionOptions) | ||
| ) { | ||
| // A skipped local date may normalize back to the previous candidate. | ||
| daysOffset += direction; | ||
| availableDate = addDays(targetDate, daysOffset); | ||
| if (!Number.isFinite(availableDate.getTime())) { | ||
| throw new RangeError('Cannot search for an out-of-range date.'); | ||
| } | ||
| } | ||
|
|
||
| if (compareDatePart(initialDate, availableDate) !== 0 && !isRestrictedDate(availableDate, restrictionOptions)) { | ||
| return availableDate; | ||
| } | ||
|
|
||
| return undefined; | ||
| }; | ||
|
|
||
| /** | ||
| * Generates a list of dates, bounded by min and max dates | ||
| * @param dateRange - input date range | ||
| * @param minDate - min date to limit the range | ||
| * @param maxDate - max date to limit the range | ||
| */ | ||
| export const getBoundedDateRange = (dateRange: Date[], minDate?: Date, maxDate?: Date): Date[] => { | ||
| let boundedDateRange = [...dateRange]; | ||
| if (minDate) { | ||
| boundedDateRange = boundedDateRange.filter(date => compareDatePart(date, minDate) >= 0); | ||
| } | ||
| if (maxDate) { | ||
| boundedDateRange = boundedDateRange.filter(date => compareDatePart(date, maxDate) <= 0); | ||
| } | ||
| return boundedDateRange; | ||
| }; | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.