v1.0 with SW PWA enabled

This commit is contained in:
Blomios
2026-01-01 17:40:53 +01:00
parent 1c0e22aac1
commit 3c8bebb2ad
29775 changed files with 2197201 additions and 119080 deletions

View File

@ -0,0 +1,210 @@
/* eslint-disable testing-library/render-result-naming-convention */
import { es } from 'date-fns/locale';
import { DayPickerProps } from 'DayPicker';
import { renderDayPickerHook } from 'test/render';
import { freezeBeforeAll } from 'test/utils';
import { CaptionLayout } from 'components/Caption';
import { DayPickerContextValue, useDayPicker } from 'contexts/DayPicker';
import {
DefaultContextProps,
getDefaultContextValues
} from 'contexts/DayPicker/defaultContextValues';
import { DaySelectionMode } from 'types/DayPickerBase';
import { Formatters } from 'types/Formatters';
import { Labels } from 'types/Labels';
import { DayModifiers, ModifiersClassNames } from 'types/Modifiers';
import { ClassNames, Styles } from 'types/Styles';
const today = new Date(2022, 5, 13);
const defaults = getDefaultContextValues();
freezeBeforeAll(today);
function renderHook(props?: DayPickerProps) {
return renderDayPickerHook<DayPickerContextValue>(useDayPicker, props);
}
describe('when rendered without props', () => {
const testPropNames = Object.keys(defaults).filter(
(key) => key !== 'today'
) as DefaultContextProps[];
test.each(testPropNames)('should use the %s default value', (propName) => {
const result = renderHook();
expect(result.current[propName]).toEqual(defaults[propName]);
});
});
describe('when passing "locale" from props', () => {
const locale = es;
test('should return the custom locale', () => {
const result = renderHook({ locale });
expect(result.current.locale).toBe(locale);
});
});
describe('when passing "numberOfMonths" from props', () => {
const numberOfMonths = 4;
test('should return the custom numberOfMonths', () => {
const result = renderHook({ numberOfMonths });
expect(result.current.numberOfMonths).toBe(4);
});
});
describe('when passing "today" from props', () => {
const today = new Date(2010, 9, 11);
test('should return the custom "today"', () => {
const result = renderHook({ today });
expect(result.current.today).toBe(today);
});
});
describe('when passing "captionLayout" from props', () => {
const captionLayout: CaptionLayout = 'dropdown';
const fromYear = 2000;
const toYear = 2010;
const dayPickerProps: DayPickerProps = { captionLayout, fromYear, toYear };
test('should return the custom "captionLayout"', () => {
const result = renderHook(dayPickerProps);
expect(result.current.captionLayout).toBe(captionLayout);
});
});
describe('when "fromDate" and "toDate" are undefined', () => {
const fromDate = undefined;
const toDate = undefined;
describe('when using "dropdown" as "captionLayout"', () => {
const captionLayout: CaptionLayout = 'dropdown';
test('should return "buttons" as "captionLayout"', () => {
const result = renderHook({
fromDate,
toDate,
captionLayout
});
expect(result.current.captionLayout).toBe('buttons');
});
});
});
describe('when "fromDate" is undefined, but not "toDate"', () => {
const fromDate = undefined;
const toDate = new Date();
describe('when using "dropdown" as "captionLayout"', () => {
const captionLayout: CaptionLayout = 'dropdown';
test('should return "buttons" as "captionLayout"', () => {
const result = renderHook({
fromDate,
toDate,
captionLayout
});
expect(result.current.captionLayout).toBe('buttons');
});
});
});
describe('when "toDate" is undefined, but not "fromDate"', () => {
const fromDate = new Date();
const toDate = undefined;
describe('when using "dropdown" as "captionLayout"', () => {
const captionLayout: CaptionLayout = 'dropdown';
test('should return "buttons" as "captionLayout"', () => {
const result = renderHook({
fromDate,
toDate,
captionLayout
});
expect(result.current.captionLayout).toBe('buttons');
});
});
});
describe('when using "dropdown" as "captionLayout"', () => {
const captionLayout: CaptionLayout = 'dropdown';
const fromYear = 2000;
const toYear = 2010;
test('should return the custom "captionLayout"', () => {
const result = renderHook({ captionLayout, fromYear, toYear });
expect(result.current.captionLayout).toBe(captionLayout);
});
});
describe('when passing "modifiers" from props', () => {
const modifiers: DayModifiers = { foo: new Date() };
test('should return the custom "modifiers"', () => {
const result = renderHook({ modifiers });
expect(result.current.modifiers).toStrictEqual(modifiers);
});
});
describe('when passing "modifiersClassNames" from props', () => {
const modifiersClassNames: ModifiersClassNames = { foo: 'bar' };
test('should return the custom "modifiersClassNames"', () => {
const result = renderHook({ modifiersClassNames });
expect(result.current.modifiersClassNames).toStrictEqual(
modifiersClassNames
);
});
});
describe('when passing "styles" from props', () => {
const styles: Styles = { caption: { color: 'red ' } };
test('should include the custom "styles"', () => {
const result = renderHook({ styles });
expect(result.current.styles).toStrictEqual({
...defaults.styles,
...styles
});
});
});
describe('when passing "classNames" from props', () => {
const classNames: ClassNames = { caption: 'foo' };
test('should include the custom "classNames"', () => {
const result = renderHook({ classNames });
expect(result.current.classNames).toStrictEqual({
...defaults.classNames,
...classNames
});
});
});
describe('when passing "formatters" from props', () => {
const formatters: Partial<Formatters> = { formatCaption: jest.fn() };
test('should include the custom "formatters"', () => {
const result = renderHook({ formatters });
expect(result.current.formatters).toStrictEqual({
...defaults.formatters,
...formatters
});
});
});
describe('when passing "labels" from props', () => {
const labels: Partial<Labels> = { labelDay: jest.fn() };
test('should include the custom "labels"', () => {
const result = renderHook({ labels });
expect(result.current.labels).toStrictEqual({
...defaults.labels,
...labels
});
});
});
describe('when passing an "id" from props', () => {
test('should return the id', () => {
const result = renderHook({ id: 'foo' });
expect(result.current.id).toBe('foo');
});
});
describe('when in selection mode', () => {
const mode: DaySelectionMode = 'multiple';
const onSelect = jest.fn();
test('should return the "onSelect" event handler', () => {
const result = renderHook({ mode, onSelect });
expect(result.current.onSelect).toBe(onSelect);
});
});

View File

@ -0,0 +1,150 @@
import { createContext, ReactNode, useContext } from 'react';
import { Locale } from 'date-fns';
import { DayPickerProps } from 'DayPicker';
import { CaptionLayout } from 'components/Caption';
import { DayPickerBase, DaySelectionMode } from 'types/DayPickerBase';
import {
DayPickerMultipleProps,
isDayPickerMultiple
} from 'types/DayPickerMultiple';
import { DayPickerRangeProps, isDayPickerRange } from 'types/DayPickerRange';
import { DayPickerSingleProps, isDayPickerSingle } from 'types/DayPickerSingle';
import { Formatters } from 'types/Formatters';
import { Labels } from 'types/Labels';
import { Matcher } from 'types/Matchers';
import { DayModifiers, ModifiersClassNames } from 'types/Modifiers';
import { ClassNames, Styles } from 'types/Styles';
import { getDefaultContextValues } from './defaultContextValues';
import { parseFromToProps } from './utils';
/**
* The value of the {@link DayPickerContext} extends the props from DayPicker
* with default and cleaned up values.
*/
export interface DayPickerContextValue extends DayPickerBase {
mode: DaySelectionMode;
onSelect?:
| DayPickerSingleProps['onSelect']
| DayPickerMultipleProps['onSelect']
| DayPickerRangeProps['onSelect'];
required?: boolean;
min?: number;
max?: number;
selected?: Matcher | Matcher[];
captionLayout: CaptionLayout;
classNames: Required<ClassNames>;
formatters: Formatters;
labels: Labels;
locale: Locale;
modifiersClassNames: ModifiersClassNames;
modifiers: DayModifiers;
numberOfMonths: number;
styles: Styles;
today: Date;
}
/**
* The DayPicker context shares the props passed to DayPicker within internal
* and custom components. It is used to set the default values and perform
* one-time calculations required to render the days.
*
* Access to this context from the {@link useDayPicker} hook.
*/
export const DayPickerContext = createContext<
DayPickerContextValue | undefined
>(undefined);
/** The props for the {@link DayPickerProvider}. */
export interface DayPickerProviderProps {
/** The initial props from the DayPicker component. */
initialProps: DayPickerProps;
children?: ReactNode;
}
/**
* The provider for the {@link DayPickerContext}, assigning the defaults from the
* initial DayPicker props.
*/
export function DayPickerProvider(props: DayPickerProviderProps): JSX.Element {
const { initialProps } = props;
const defaultContextValues = getDefaultContextValues();
const { fromDate, toDate } = parseFromToProps(initialProps);
let captionLayout =
initialProps.captionLayout ?? defaultContextValues.captionLayout;
if (captionLayout !== 'buttons' && (!fromDate || !toDate)) {
// When no from/to dates are set, the caption is always buttons
captionLayout = 'buttons';
}
let onSelect;
if (
isDayPickerSingle(initialProps) ||
isDayPickerMultiple(initialProps) ||
isDayPickerRange(initialProps)
) {
onSelect = initialProps.onSelect;
}
const value: DayPickerContextValue = {
...defaultContextValues,
...initialProps,
captionLayout,
classNames: {
...defaultContextValues.classNames,
...initialProps.classNames
},
components: {
...initialProps.components
},
formatters: {
...defaultContextValues.formatters,
...initialProps.formatters
},
fromDate,
labels: {
...defaultContextValues.labels,
...initialProps.labels
},
mode: initialProps.mode || defaultContextValues.mode,
modifiers: {
...defaultContextValues.modifiers,
...initialProps.modifiers
},
modifiersClassNames: {
...defaultContextValues.modifiersClassNames,
...initialProps.modifiersClassNames
},
onSelect,
styles: {
...defaultContextValues.styles,
...initialProps.styles
},
toDate
};
return (
<DayPickerContext.Provider value={value}>
{props.children}
</DayPickerContext.Provider>
);
}
/**
* Hook to access the {@link DayPickerContextValue}.
*
* Use the DayPicker context to access to the props passed to DayPicker inside
* internal or custom components.
*/
export function useDayPicker(): DayPickerContextValue {
const context = useContext(DayPickerContext);
if (!context) {
throw new Error(`useDayPicker must be used within a DayPickerProvider.`);
}
return context;
}

View File

@ -0,0 +1,58 @@
import { ClassNames } from 'types/Styles';
/**
* The name of the default CSS classes.
*/
export const defaultClassNames: Required<ClassNames> = {
root: 'rdp',
multiple_months: 'rdp-multiple_months',
with_weeknumber: 'rdp-with_weeknumber',
vhidden: 'rdp-vhidden',
button_reset: 'rdp-button_reset',
button: 'rdp-button',
caption: 'rdp-caption',
caption_start: 'rdp-caption_start',
caption_end: 'rdp-caption_end',
caption_between: 'rdp-caption_between',
caption_label: 'rdp-caption_label',
caption_dropdowns: 'rdp-caption_dropdowns',
dropdown: 'rdp-dropdown',
dropdown_month: 'rdp-dropdown_month',
dropdown_year: 'rdp-dropdown_year',
dropdown_icon: 'rdp-dropdown_icon',
months: 'rdp-months',
month: 'rdp-month',
table: 'rdp-table',
tbody: 'rdp-tbody',
tfoot: 'rdp-tfoot',
head: 'rdp-head',
head_row: 'rdp-head_row',
head_cell: 'rdp-head_cell',
nav: 'rdp-nav',
nav_button: 'rdp-nav_button',
nav_button_previous: 'rdp-nav_button_previous',
nav_button_next: 'rdp-nav_button_next',
nav_icon: 'rdp-nav_icon',
row: 'rdp-row',
weeknumber: 'rdp-weeknumber',
cell: 'rdp-cell',
day: 'rdp-day',
day_today: 'rdp-day_today',
day_outside: 'rdp-day_outside',
day_selected: 'rdp-day_selected',
day_disabled: 'rdp-day_disabled',
day_hidden: 'rdp-day_hidden',
day_range_start: 'rdp-day_range_start',
day_range_end: 'rdp-day_range_end',
day_range_middle: 'rdp-day_range_middle'
};

View File

@ -0,0 +1,54 @@
import { enUS } from 'date-fns/locale';
import { CaptionLayout } from 'components/Caption';
import { DayPickerContextValue } from 'contexts/DayPicker';
import { defaultClassNames } from './defaultClassNames';
import * as formatters from './formatters';
import * as labels from './labels';
export type DefaultContextProps =
| 'captionLayout'
| 'classNames'
| 'formatters'
| 'locale'
| 'labels'
| 'modifiersClassNames'
| 'modifiers'
| 'numberOfMonths'
| 'styles'
| 'today'
| 'mode';
export type DefaultContextValues = Pick<
DayPickerContextValue,
DefaultContextProps
>;
/**
* Returns the default values to use in the DayPickerContext, in case they are
* not passed down with the DayPicker initial props.
*/
export function getDefaultContextValues(): DefaultContextValues {
const captionLayout: CaptionLayout = 'buttons';
const classNames = defaultClassNames;
const locale = enUS;
const modifiersClassNames = {};
const modifiers = {};
const numberOfMonths = 1;
const styles = {};
const today = new Date();
return {
captionLayout,
classNames,
formatters,
labels,
locale,
modifiersClassNames,
modifiers,
numberOfMonths,
styles,
today,
mode: 'default'
};
}

View File

@ -0,0 +1,15 @@
import { es } from 'date-fns/locale';
import { formatCaption } from './formatCaption';
const date = new Date(2022, 10, 21);
test('should return the formatted caption', () => {
expect(formatCaption(date)).toEqual('November 2022');
});
describe('when a locale is passed in', () => {
test('should format using the locale', () => {
expect(formatCaption(date, { locale: es })).toEqual('noviembre 2022');
});
});

View File

@ -0,0 +1,11 @@
import { format, Locale } from 'date-fns';
/**
* The default formatter for the caption.
*/
export function formatCaption(
month: Date,
options?: { locale?: Locale }
): string {
return format(month, 'LLLL y', options);
}

View File

@ -0,0 +1,7 @@
import { formatDay } from './formatDay';
const date = new Date(2022, 10, 21);
test('should return the formatted day', () => {
expect(formatDay(date)).toEqual('21');
});

View File

@ -0,0 +1,8 @@
import { format, Locale } from 'date-fns';
/**
* The default formatter for the Day button.
*/
export function formatDay(day: Date, options?: { locale?: Locale }): string {
return format(day, 'd', options);
}

View File

@ -0,0 +1,15 @@
import { es } from 'date-fns/locale';
import { formatMonthCaption } from './formatMonthCaption';
const date = new Date(2022, 10, 21);
test('should return the formatted month caption', () => {
expect(formatMonthCaption(date)).toEqual('November');
});
describe('when a locale is passed in', () => {
test('should format using the locale', () => {
expect(formatMonthCaption(date, { locale: es })).toEqual('noviembre');
});
});

View File

@ -0,0 +1,11 @@
import { format, Locale } from 'date-fns';
/**
* The default formatter for the Month caption.
*/
export function formatMonthCaption(
month: Date,
options?: { locale?: Locale }
): string {
return format(month, 'LLLL', options);
}

View File

@ -0,0 +1,5 @@
import { formatWeekNumber } from './formatWeekNumber';
test('should return the formatted week number', () => {
expect(formatWeekNumber(10)).toEqual('10');
});

View File

@ -0,0 +1,6 @@
/**
* The default formatter for the week number.
*/
export function formatWeekNumber(weekNumber: number): string {
return `${weekNumber}`;
}

View File

@ -0,0 +1,15 @@
import { es } from 'date-fns/locale';
import { formatWeekdayName } from './formatWeekdayName';
const date = new Date(2022, 10, 21);
test('should return the formatted weekday name', () => {
expect(formatWeekdayName(date)).toEqual('Mo');
});
describe('when a locale is passed in', () => {
test('should format using the locale', () => {
expect(formatWeekdayName(date, { locale: es })).toEqual('lu');
});
});

View File

@ -0,0 +1,11 @@
import { format, Locale } from 'date-fns';
/**
* The default formatter for the name of the weekday.
*/
export function formatWeekdayName(
weekday: Date,
options?: { locale?: Locale }
): string {
return format(weekday, 'cccccc', options);
}

View File

@ -0,0 +1,7 @@
import { formatYearCaption } from './formatYearCaption';
const date = new Date(2022, 10, 21);
test('should return the formatted weekday name', () => {
expect(formatYearCaption(date)).toEqual('2022');
});

View File

@ -0,0 +1,13 @@
import { format, Locale } from 'date-fns';
/**
* The default formatter for the Year caption.
*/
export function formatYearCaption(
year: Date,
options?: {
locale?: Locale;
}
): string {
return format(year, 'yyyy', options);
}

View File

@ -0,0 +1,6 @@
export * from './formatCaption';
export * from './formatDay';
export * from './formatMonthCaption';
export * from './formatWeekNumber';
export * from './formatWeekdayName';
export * from './formatYearCaption';

View File

@ -0,0 +1 @@
export * from './DayPickerContext';

View File

@ -0,0 +1,7 @@
export * from './labelDay';
export * from './labelMonthDropdown';
export * from './labelNext';
export * from './labelPrevious';
export * from './labelWeekday';
export * from './labelWeekNumber';
export * from './labelYearDropdown';

View File

@ -0,0 +1,7 @@
import { labelDay } from './labelDay';
const day = new Date(2022, 10, 21);
test('should return the day label', () => {
expect(labelDay(day, {})).toEqual('21st November (Monday)');
});

View File

@ -0,0 +1,10 @@
import { format } from 'date-fns';
import { DayLabel } from 'types/Labels';
/**
* The default ARIA label for the day button.
*/
export const labelDay: DayLabel = (day, activeModifiers, options): string => {
return format(day, 'do MMMM (EEEE)', options);
};

View File

@ -0,0 +1,5 @@
import { labelMonthDropdown } from './labelMonthDropdown';
test('should return the label', () => {
expect(labelMonthDropdown()).toEqual('Month: ');
});

View File

@ -0,0 +1,6 @@
/**
* The default ARIA label for the WeekNumber element.
*/
export const labelMonthDropdown = (): string => {
return 'Month: ';
};

View File

@ -0,0 +1,5 @@
import { labelNext } from './labelNext';
test('should return the label', () => {
expect(labelNext()).toEqual('Go to next month');
});

View File

@ -0,0 +1,8 @@
import { NavButtonLabel } from 'types/Labels';
/**
* The default ARIA label for next month button in navigation
*/
export const labelNext: NavButtonLabel = (): string => {
return 'Go to next month';
};

View File

@ -0,0 +1,5 @@
import { labelPrevious } from './labelPrevious';
test('should return the label', () => {
expect(labelPrevious()).toEqual('Go to previous month');
});

View File

@ -0,0 +1,8 @@
import { NavButtonLabel } from 'types/Labels';
/**
* The default ARIA label for previous month button in navigation
*/
export const labelPrevious: NavButtonLabel = (): string => {
return 'Go to previous month';
};

View File

@ -0,0 +1,5 @@
import { labelWeekNumber } from './labelWeekNumber';
test('should return the label', () => {
expect(labelWeekNumber(2)).toEqual('Week n. 2');
});

View File

@ -0,0 +1,8 @@
import { WeekNumberLabel } from 'types/Labels';
/**
* The default ARIA label for the WeekNumber element.
*/
export const labelWeekNumber: WeekNumberLabel = (n): string => {
return `Week n. ${n}`;
};

View File

@ -0,0 +1,15 @@
import { es } from 'date-fns/locale';
import { labelWeekday } from './labelWeekday';
const weekDay = new Date(2022, 10, 21);
test('should return the formatted weekday name', () => {
expect(labelWeekday(weekDay)).toEqual('Monday');
});
describe('when a locale is passed in', () => {
test('should format using the locale', () => {
expect(labelWeekday(weekDay, { locale: es })).toEqual('lunes');
});
});

View File

@ -0,0 +1,10 @@
import { format } from 'date-fns';
import { WeekdayLabel } from 'types/Labels';
/**
* The default ARIA label for the Weekday element.
*/
export const labelWeekday: WeekdayLabel = (day, options): string => {
return format(day, 'cccc', options);
};

View File

@ -0,0 +1,5 @@
import { labelYearDropdown } from './labelYearDropdown';
test('should return the label', () => {
expect(labelYearDropdown()).toEqual('Year: ');
});

View File

@ -0,0 +1,6 @@
/**
* The default ARIA label for the WeekNumber element.
*/
export const labelYearDropdown = (): string => {
return 'Year: ';
};

View File

@ -0,0 +1 @@
export * from './parseFromToProps';

View File

@ -0,0 +1,47 @@
import { parseFromToProps } from 'contexts/DayPicker/utils';
describe('when "fromMonth" is passed in', () => {
const fromMonth = new Date(2021, 4, 3);
const expectedFromDate = new Date(2021, 4, 1);
const { fromDate } = parseFromToProps({ fromMonth });
test('"fromDate" should be the start of that month', () => {
expect(fromDate).toEqual(expectedFromDate);
});
describe('when "fromYear" is passed in', () => {
test('"fromDate" should be the start of that month', () => {
expect(fromDate).toEqual(expectedFromDate);
});
});
});
describe('when "fromYear" is passed in', () => {
const fromYear = 2021;
const expectedFromDate = new Date(2021, 0, 1);
const { fromDate } = parseFromToProps({ fromYear });
test('"fromDate" should be the start of that year', () => {
expect(fromDate).toEqual(expectedFromDate);
});
});
describe('when "toMonth" is passed in', () => {
const toMonth = new Date(2021, 4, 3);
const expectedToDate = new Date(2021, 4, 31);
const { toDate } = parseFromToProps({ toMonth });
test('"toDate" should be the end of that month', () => {
expect(toDate).toEqual(expectedToDate);
});
describe('when "fromYear" is passed in', () => {
test('"toDate" should be the end of that month', () => {
expect(toDate).toEqual(expectedToDate);
});
});
});
describe('when "toYear" is passed in', () => {
const toYear = 2021;
const expectedToDate = new Date(2021, 11, 31);
const { toDate } = parseFromToProps({ toYear });
test('"toDate" should be the end of that year', () => {
expect(toDate).toEqual(expectedToDate);
});
});

View File

@ -0,0 +1,30 @@
import { endOfMonth, startOfDay, startOfMonth } from 'date-fns';
import { DayPickerBase } from 'types/DayPickerBase';
/** Return the `fromDate` and `toDate` prop values values parsing the DayPicker props. */
export function parseFromToProps(
props: Pick<
DayPickerBase,
'fromYear' | 'toYear' | 'fromDate' | 'toDate' | 'fromMonth' | 'toMonth'
>
): { fromDate: Date | undefined; toDate: Date | undefined } {
const { fromYear, toYear, fromMonth, toMonth } = props;
let { fromDate, toDate } = props;
if (fromMonth) {
fromDate = startOfMonth(fromMonth);
} else if (fromYear) {
fromDate = new Date(fromYear, 0, 1);
}
if (toMonth) {
toDate = endOfMonth(toMonth);
} else if (toYear) {
toDate = new Date(toYear, 11, 31);
}
return {
fromDate: fromDate ? startOfDay(fromDate) : undefined,
toDate: toDate ? startOfDay(toDate) : undefined
};
}

View File

@ -0,0 +1,156 @@
import { act } from '@testing-library/react';
import {
addDays,
addMonths,
addWeeks,
addYears,
endOfWeek,
startOfWeek
} from 'date-fns';
import { renderDayPickerHook, RenderHookResult } from 'test/render';
import { freezeBeforeAll } from 'test/utils';
import { FocusContextValue, useFocusContext } from 'contexts/Focus';
const today = new Date(2021, 11, 8); // make sure is in the middle of the week for the complete test
freezeBeforeAll(today);
function renderHook() {
return renderDayPickerHook<FocusContextValue>(useFocusContext);
}
type HookFunction =
| 'focusDayAfter'
| 'focusDayBefore'
| 'focusWeekAfter'
| 'focusWeekBefore'
| 'focusMonthBefore'
| 'focusMonthAfter'
| 'focusYearBefore'
| 'focusYearAfter'
| 'focusStartOfWeek'
| 'focusEndOfWeek';
test('`focusedDay` should be undefined', () => {
const result = renderHook();
expect(result.current.focusedDay).toBeUndefined();
});
const tests: Array<HookFunction> = [
'focusDayAfter',
'focusDayBefore',
'focusWeekAfter',
'focusWeekBefore',
'focusMonthBefore',
'focusMonthAfter',
'focusYearBefore',
'focusYearAfter',
'focusStartOfWeek',
'focusEndOfWeek'
];
describe.each(tests)('when calling %s', (fn: HookFunction) => {
test('`focusedDay` should be undefined', () => {
const result = renderHook();
result.current[fn];
expect(result.current.focusedDay).toBeUndefined();
});
});
describe('when a day is focused', () => {
const day = today;
let result: RenderHookResult<FocusContextValue>;
beforeEach(() => {
result = renderHook();
act(() => result.current.focus(day));
});
test('should set the focused day', () => {
expect(result.current.focusedDay).toEqual(day);
});
describe('when "focusDayBefore" is called', () => {
const dayBefore = addDays(day, -1);
beforeEach(() => act(() => result.current.focusDayBefore()));
test('should focus the day before', () => {
expect(result.current.focusedDay).toEqual(dayBefore);
});
test.todo('should call the navigation goToDate');
});
describe('when "focusDayAfter" is called', () => {
beforeEach(() => act(() => result.current.focusDayAfter()));
test('should focus the day after', () => {
const dayAfter = addDays(day, 1);
expect(result.current.focusedDay).toEqual(dayAfter);
});
test.todo('should call the navigation goToDate');
});
describe('when "focusWeekBefore" is called', () => {
beforeEach(() => act(() => result.current.focusWeekBefore()));
test('should focus the day in the previous week', () => {
const prevWeek = addWeeks(day, -1);
expect(result.current.focusedDay).toEqual(prevWeek);
});
test.todo('should call the navigation goToDate');
});
describe('when "focusWeekAfter" is called', () => {
beforeEach(() => act(() => result.current.focusWeekAfter()));
test('should focus the day in the next week', () => {
const nextWeek = addWeeks(day, 1);
expect(result.current.focusedDay).toEqual(nextWeek);
});
test.todo('should call the navigation goToDate');
});
describe('when "focusStartOfWeek" is called', () => {
beforeEach(() => act(() => result.current.focusStartOfWeek()));
test('should focus the first day of the week', () => {
const firstDayOfWeek = startOfWeek(day);
expect(result.current.focusedDay).toEqual(firstDayOfWeek);
});
test.todo('should call the navigation goToDate');
});
describe('when "focusEndOfWeek" is called', () => {
beforeEach(() => act(() => result.current.focusEndOfWeek()));
test('should focus the last day of the week', () => {
const lastDayOfWeek = endOfWeek(day);
expect(result.current.focusedDay).toEqual(lastDayOfWeek);
});
test.todo('should call the navigation goToDate');
});
describe('when "focusMonthBefore" is called', () => {
beforeEach(() => act(() => result.current.focusMonthBefore()));
test('should focus the day in the month before', () => {
const monthBefore = addMonths(day, -1);
expect(result.current.focusedDay).toEqual(monthBefore);
});
test.todo('should call the navigation goToDate');
});
describe('when "focusMonthAfter" is called', () => {
beforeEach(() => act(() => result.current.focusMonthAfter()));
test('should focus the day in the month after', () => {
const monthAfter = addMonths(day, 1);
expect(result.current.focusedDay).toEqual(monthAfter);
});
test.todo('should call the navigation goToDate');
});
describe('when "focusYearBefore" is called', () => {
beforeEach(() => act(() => result.current.focusYearBefore()));
test('should focus the day in the year before', () => {
const prevYear = addYears(day, -1);
expect(result.current.focusedDay).toEqual(prevYear);
});
test.todo('should call the navigation goToDate');
});
describe('when "focusYearAfter" is called', () => {
beforeEach(() => act(() => result.current.focusYearAfter()));
test('should focus the day in the year after', () => {
const nextYear = addYears(day, 1);
expect(result.current.focusedDay).toEqual(nextYear);
});
test.todo('should call the navigation goToDate');
});
describe('when blur is called', () => {
beforeEach(() => act(() => result.current.blur()));
test('`focusedDay` should be undefined', () => {
expect(result.current.focusedDay).toBeUndefined();
});
});
});

View File

@ -0,0 +1,137 @@
import { createContext, ReactNode, useContext, useState } from 'react';
import { isSameDay } from 'date-fns';
import { useDayPicker } from 'contexts/DayPicker';
import { useModifiers } from '../Modifiers';
import { useNavigation } from '../Navigation';
import { getInitialFocusTarget } from './utils/getInitialFocusTarget';
import {
getNextFocus,
MoveFocusBy,
MoveFocusDirection
} from './utils/getNextFocus';
/** Represents the value of the {@link FocusContext}. */
export type FocusContextValue = {
/** The day currently focused. */
focusedDay: Date | undefined;
/** Day that will be focused. */
focusTarget: Date | undefined;
/** Focus a day. */
focus: (day: Date) => void;
/** Blur the focused day. */
blur: () => void;
/** Focus the day after the focused day. */
focusDayAfter: () => void;
/** Focus the day before the focused day. */
focusDayBefore: () => void;
/** Focus the day in the week before the focused day. */
focusWeekBefore: () => void;
/** Focus the day in the week after the focused day. */
focusWeekAfter: () => void;
/* Focus the day in the month before the focused day. */
focusMonthBefore: () => void;
/* Focus the day in the month after the focused day. */
focusMonthAfter: () => void;
/* Focus the day in the year before the focused day. */
focusYearBefore: () => void;
/* Focus the day in the year after the focused day. */
focusYearAfter: () => void;
/* Focus the day at the start of the week of the focused day. */
focusStartOfWeek: () => void;
/* Focus the day at the end of the week of focused day. */
focusEndOfWeek: () => void;
};
/**
* The Focus context shares details about the focused day for the keyboard
*
* Access this context from the {@link useFocusContext} hook.
*/
export const FocusContext = createContext<FocusContextValue | undefined>(
undefined
);
export type FocusProviderProps = { children: ReactNode };
/** The provider for the {@link FocusContext}. */
export function FocusProvider(props: FocusProviderProps): JSX.Element {
const navigation = useNavigation();
const modifiers = useModifiers();
const [focusedDay, setFocusedDay] = useState<Date | undefined>();
const [lastFocused, setLastFocused] = useState<Date | undefined>();
const initialFocusTarget = getInitialFocusTarget(
navigation.displayMonths,
modifiers
);
// TODO: cleanup and test obscure code below
const focusTarget =
focusedDay ?? (lastFocused && navigation.isDateDisplayed(lastFocused))
? lastFocused
: initialFocusTarget;
const blur = () => {
setLastFocused(focusedDay);
setFocusedDay(undefined);
};
const focus = (date: Date) => {
setFocusedDay(date);
};
const context = useDayPicker();
const moveFocus = (moveBy: MoveFocusBy, direction: MoveFocusDirection) => {
if (!focusedDay) return;
const nextFocused = getNextFocus(focusedDay, {
moveBy,
direction,
context,
modifiers
});
if (isSameDay(focusedDay, nextFocused)) return undefined;
navigation.goToDate(nextFocused, focusedDay);
focus(nextFocused);
};
const value: FocusContextValue = {
focusedDay,
focusTarget,
blur,
focus,
focusDayAfter: () => moveFocus('day', 'after'),
focusDayBefore: () => moveFocus('day', 'before'),
focusWeekAfter: () => moveFocus('week', 'after'),
focusWeekBefore: () => moveFocus('week', 'before'),
focusMonthBefore: () => moveFocus('month', 'before'),
focusMonthAfter: () => moveFocus('month', 'after'),
focusYearBefore: () => moveFocus('year', 'before'),
focusYearAfter: () => moveFocus('year', 'after'),
focusStartOfWeek: () => moveFocus('startOfWeek', 'before'),
focusEndOfWeek: () => moveFocus('endOfWeek', 'after')
};
return (
<FocusContext.Provider value={value}>
{props.children}
</FocusContext.Provider>
);
}
/**
* Hook to access the {@link FocusContextValue}. Use this hook to handle the
* focus state of the elements.
*
* This hook is meant to be used inside internal or custom components.
*/
export function useFocusContext(): FocusContextValue {
const context = useContext(FocusContext);
if (!context) {
throw new Error('useFocusContext must be used within a FocusProvider');
}
return context;
}

View File

@ -0,0 +1 @@
export * from './FocusContext';

View File

@ -0,0 +1,41 @@
import { Modifiers } from 'types/Modifiers';
import { getInitialFocusTarget } from './getInitialFocusTarget';
describe('when no days are selected is selected', () => {
test('should return the first day of month', () => {
const displayMonth = new Date(2022, 7);
const modifiers: Modifiers = {
outside: [],
disabled: [],
selected: [],
hidden: [],
today: [],
range_start: [],
range_end: [],
range_middle: []
};
const initialFocusTarget = getInitialFocusTarget([displayMonth], modifiers);
expect(initialFocusTarget).toStrictEqual(displayMonth);
});
});
describe('when a day is selected', () => {
test('should return the selected day', () => {
const displayMonths = [new Date(2022, 7)];
const selectedDay1 = new Date(2022, 7, 17);
const selectedDay2 = new Date(2022, 7, 19);
const modifiers: Modifiers = {
outside: [],
disabled: [],
selected: [selectedDay1, selectedDay2],
hidden: [],
today: [],
range_start: [],
range_end: [],
range_middle: []
};
const initialFocusTarget = getInitialFocusTarget(displayMonths, modifiers);
expect(initialFocusTarget).toStrictEqual(selectedDay1);
});
});

View File

@ -0,0 +1,48 @@
import { addDays, endOfMonth, startOfMonth } from 'date-fns';
import { getActiveModifiers } from 'contexts/Modifiers';
import { Modifiers } from 'types/Modifiers';
/**
* Returns the day that should be the target of the focus when DayPicker is
* rendered the first time.
*
* TODO: this function doesn't consider if the day is outside the month. We
* implemented this check in `useDayRender` but it should probably go here. See
* https://github.com/gpbl/react-day-picker/pull/1576
*/
export function getInitialFocusTarget(
displayMonths: Date[],
modifiers: Modifiers
) {
const firstDayInMonth = startOfMonth(displayMonths[0]);
const lastDayInMonth = endOfMonth(displayMonths[displayMonths.length - 1]);
// TODO: cleanup code
let firstFocusableDay;
let today;
let date = firstDayInMonth;
while (date <= lastDayInMonth) {
const activeModifiers = getActiveModifiers(date, modifiers);
const isFocusable = !activeModifiers.disabled && !activeModifiers.hidden;
if (!isFocusable) {
date = addDays(date, 1);
continue;
}
if (activeModifiers.selected) {
return date;
}
if (activeModifiers.today && !today) {
today = date;
}
if (!firstFocusableDay) {
firstFocusableDay = date;
}
date = addDays(date, 1);
}
if (today) {
return today;
} else {
return firstFocusableDay;
}
}

View File

@ -0,0 +1,264 @@
/* eslint-disable jest/no-standalone-expect */
import { addDays, format, parseISO } from 'date-fns';
import {
InternalModifier,
InternalModifiers,
Modifiers
} from 'types/Modifiers';
import {
FocusDayPickerContext,
getNextFocus,
MoveFocusBy,
MoveFocusDirection
} from './getNextFocus';
type test = {
focusedDay: string;
moveBy: MoveFocusBy;
direction: MoveFocusDirection;
context: FocusDayPickerContext;
expectedNextFocus: string;
};
const tests: test[] = [
{
focusedDay: '2022-08-17',
moveBy: 'day',
direction: 'after',
context: {},
expectedNextFocus: '2022-08-18'
},
{
focusedDay: '2022-08-17',
moveBy: 'day',
direction: 'before',
context: {},
expectedNextFocus: '2022-08-16'
},
{
focusedDay: '2022-08-17',
moveBy: 'week',
direction: 'after',
context: {},
expectedNextFocus: '2022-08-24'
},
{
focusedDay: '2022-08-17',
moveBy: 'week',
direction: 'before',
context: {},
expectedNextFocus: '2022-08-10'
},
{
focusedDay: '2022-08-17',
moveBy: 'month',
direction: 'after',
context: {},
expectedNextFocus: '2022-09-17'
},
{
focusedDay: '2022-08-17',
moveBy: 'startOfWeek',
direction: 'before',
context: {
weekStartsOn: 1
},
expectedNextFocus: '2022-08-15'
},
{
focusedDay: '2022-08-17',
moveBy: 'endOfWeek',
direction: 'before',
context: {
weekStartsOn: 1
},
expectedNextFocus: '2022-08-21'
},
{
focusedDay: '2022-08-17',
moveBy: 'month',
direction: 'after',
context: {},
expectedNextFocus: '2022-09-17'
},
{
focusedDay: '2022-08-17',
moveBy: 'year',
direction: 'before',
context: {},
expectedNextFocus: '2021-08-17'
},
{
focusedDay: '2022-08-17',
moveBy: 'year',
direction: 'after',
context: {},
expectedNextFocus: '2023-08-17'
}
];
describe.each(tests)(
'when focusing the $moveBy $direction $focusedDay',
({ focusedDay, moveBy, direction, context, expectedNextFocus }) => {
test(`should return ${expectedNextFocus}`, () => {
const nextFocus = getNextFocus(parseISO(focusedDay), {
moveBy,
direction,
context
});
expect(format(nextFocus, 'yyyy-MM-dd')).toBe(expectedNextFocus);
});
}
);
describe('when reaching the "fromDate"', () => {
const focusedDay = new Date();
const fromDate = addDays(focusedDay, -1);
test('next focus should be "fromDate"', () => {
const nextFocus = getNextFocus(focusedDay, {
moveBy: 'day',
direction: 'before',
context: { fromDate }
});
expect(nextFocus).toStrictEqual(fromDate);
});
});
describe('when reaching the "toDate"', () => {
const focusedDay = new Date();
const toDate = addDays(focusedDay, 1);
test('next focus should be "toDate"', () => {
const nextFocus = getNextFocus(focusedDay, {
moveBy: 'day',
direction: 'after',
context: { toDate }
});
expect(nextFocus).toStrictEqual(toDate);
});
});
const emptyModifiers: Modifiers = {
outside: [],
disabled: [],
selected: [],
hidden: [],
today: [],
range_start: [],
range_end: [],
range_middle: []
};
type ModifiersTest = {
focusedDay: string;
skippedDay: string;
moveBy: MoveFocusBy;
direction: MoveFocusDirection;
modifierName: InternalModifier;
expectedNextFocus: string;
fromDate?: string;
toDate?: string;
};
const modifiersTest: ModifiersTest[] = [
{
focusedDay: '2022-08-17',
skippedDay: '2022-08-18',
moveBy: 'day',
direction: 'after',
modifierName: InternalModifier.Hidden,
expectedNextFocus: '2022-08-19'
},
{
focusedDay: '2022-08-17',
skippedDay: '2022-08-18',
moveBy: 'day',
direction: 'after',
modifierName: InternalModifier.Disabled,
expectedNextFocus: '2022-08-19'
},
{
focusedDay: '2022-08-17',
skippedDay: '2022-08-16',
moveBy: 'day',
direction: 'before',
modifierName: InternalModifier.Hidden,
expectedNextFocus: '2022-08-15'
},
{
focusedDay: '2022-08-17',
skippedDay: '2022-08-16',
moveBy: 'day',
direction: 'before',
modifierName: InternalModifier.Disabled,
expectedNextFocus: '2022-08-15'
},
{
focusedDay: '2022-08-17',
skippedDay: '2022-08-16',
fromDate: '2022-08-01',
moveBy: 'month',
direction: 'before',
modifierName: InternalModifier.Disabled,
expectedNextFocus: '2022-08-01'
},
{
focusedDay: '2022-08-17',
skippedDay: '2022-08-16',
toDate: '2022-08-31',
moveBy: 'month',
direction: 'after',
modifierName: InternalModifier.Disabled,
expectedNextFocus: '2022-08-31'
}
];
describe.each(modifiersTest)(
'when focusing the $moveBy $direction $focusedDay with $modifierName modifier',
(modifierTest) => {
const modifiers: InternalModifiers = {
...emptyModifiers,
[modifierTest.modifierName]: [parseISO(modifierTest.skippedDay)]
};
const context = {
fromDate: modifierTest.fromDate
? parseISO(modifierTest.fromDate)
: undefined,
toDate: modifierTest.toDate ? parseISO(modifierTest.toDate) : undefined
};
test(`should skip the ${modifierTest.modifierName} day`, () => {
const nextFocus = getNextFocus(parseISO(modifierTest.focusedDay), {
moveBy: modifierTest.moveBy,
direction: modifierTest.direction,
context,
modifiers
});
expect(format(nextFocus, 'yyyy-MM-dd')).toBe(
modifierTest.expectedNextFocus
);
});
}
);
test('should avoid infinite recursion', () => {
const focusedDay = new Date(2022, 7, 17);
const modifiers: Modifiers = {
outside: [],
disabled: [{ after: focusedDay }],
selected: [],
hidden: [],
today: [],
range_start: [],
range_end: [],
range_middle: []
};
const nextFocus = getNextFocus(focusedDay, {
moveBy: 'day',
direction: 'after',
modifiers,
context: {}
});
expect(nextFocus).toStrictEqual(focusedDay);
});

View File

@ -0,0 +1,104 @@
import {
addDays,
addMonths,
addWeeks,
addYears,
endOfISOWeek,
endOfWeek,
max,
min,
startOfISOWeek,
startOfWeek
} from 'date-fns';
import { DayPickerContextValue } from 'contexts/DayPicker';
import { getActiveModifiers } from 'contexts/Modifiers';
import { Modifiers } from 'types/Modifiers';
export type MoveFocusBy =
| 'day'
| 'week'
| 'startOfWeek'
| 'endOfWeek'
| 'month'
| 'year';
export type MoveFocusDirection = 'after' | 'before';
export type FocusDayPickerContext = Partial<
Pick<
DayPickerContextValue,
'ISOWeek' | 'weekStartsOn' | 'fromDate' | 'toDate' | 'locale'
>
>;
export type FocusDayOptions = {
moveBy: MoveFocusBy;
direction: MoveFocusDirection;
context: FocusDayPickerContext;
modifiers?: Modifiers;
retry?: { count: number; lastFocused: Date };
};
const MAX_RETRY = 365;
/** Return the next date to be focused. */
export function getNextFocus(focusedDay: Date, options: FocusDayOptions): Date {
const {
moveBy,
direction,
context,
modifiers,
retry = { count: 0, lastFocused: focusedDay }
} = options;
const { weekStartsOn, fromDate, toDate, locale } = context;
const moveFns = {
day: addDays,
week: addWeeks,
month: addMonths,
year: addYears,
startOfWeek: (date: Date) =>
context.ISOWeek
? startOfISOWeek(date)
: startOfWeek(date, { locale, weekStartsOn }),
endOfWeek: (date: Date) =>
context.ISOWeek
? endOfISOWeek(date)
: endOfWeek(date, { locale, weekStartsOn })
};
let newFocusedDay = moveFns[moveBy](
focusedDay,
direction === 'after' ? 1 : -1
);
if (direction === 'before' && fromDate) {
newFocusedDay = max([fromDate, newFocusedDay]);
} else if (direction === 'after' && toDate) {
newFocusedDay = min([toDate, newFocusedDay]);
}
let isFocusable = true;
if (modifiers) {
const activeModifiers = getActiveModifiers(newFocusedDay, modifiers);
isFocusable = !activeModifiers.disabled && !activeModifiers.hidden;
}
if (isFocusable) {
return newFocusedDay;
} else {
if (retry.count > MAX_RETRY) {
return retry.lastFocused;
}
return getNextFocus(newFocusedDay, {
moveBy,
direction,
context,
modifiers,
retry: {
...retry,
count: retry.count + 1
}
});
}
}

View File

@ -0,0 +1,40 @@
import { DayPickerProps } from 'DayPicker';
import { renderDayPickerHook } from 'test/render';
import { useModifiers } from 'contexts/Modifiers';
import { DayModifiers, InternalModifier, Modifiers } from 'types/Modifiers';
const internalModifiers = Object.values(InternalModifier);
function renderHook(dayPickerProps: Partial<DayPickerProps> = {}) {
return renderDayPickerHook<Modifiers>(useModifiers, dayPickerProps);
}
describe('when rendered with custom modifiers', () => {
const modifier = new Date(2018, 11, 12);
const dayModifiers: DayModifiers = {
foo: modifier,
today: modifier,
outside: modifier,
disabled: modifier,
selected: modifier,
hidden: modifier,
range_start: modifier,
range_end: modifier,
range_middle: modifier
};
test('should return the custom modifiers', () => {
const result = renderHook({ modifiers: dayModifiers });
expect(result.current.foo).toEqual([dayModifiers.foo]);
});
test.each(internalModifiers)(
'should override the %s internal modifier',
(internalModifier) => {
const result = renderHook({ modifiers: dayModifiers });
expect(result.current[internalModifier]).toEqual([
dayModifiers[internalModifier]
]);
}
);
});

View File

@ -0,0 +1,57 @@
import { createContext, useContext, ReactNode } from 'react';
import { useDayPicker } from 'contexts/DayPicker';
import { useSelectMultiple } from 'contexts/SelectMultiple';
import { useSelectRange } from 'contexts/SelectRange';
import { CustomModifiers, InternalModifiers, Modifiers } from 'types/Modifiers';
import { getCustomModifiers } from './utils/getCustomModifiers';
import { getInternalModifiers } from './utils/getInternalModifiers';
/** The Modifiers context store the modifiers used in DayPicker. To access the value of this context, use {@link useModifiers}. */
export const ModifiersContext = createContext<Modifiers | undefined>(undefined);
export type ModifiersProviderProps = { children: ReactNode };
/** Provide the value for the {@link ModifiersContext}. */
export function ModifiersProvider(props: ModifiersProviderProps): JSX.Element {
const dayPicker = useDayPicker();
const selectMultiple = useSelectMultiple();
const selectRange = useSelectRange();
const internalModifiers: InternalModifiers = getInternalModifiers(
dayPicker,
selectMultiple,
selectRange
);
const customModifiers: CustomModifiers = getCustomModifiers(
dayPicker.modifiers
);
const modifiers: Modifiers = {
...internalModifiers,
...customModifiers
};
return (
<ModifiersContext.Provider value={modifiers}>
{props.children}
</ModifiersContext.Provider>
);
}
/**
* Return the modifiers used by DayPicker.
*
* This hook is meant to be used inside internal or custom components.
* Requires to be wrapped into {@link ModifiersProvider}.
*
*/
export function useModifiers(): Modifiers {
const context = useContext(ModifiersContext);
if (!context) {
throw new Error('useModifiers must be used within a ModifiersProvider');
}
return context;
}

View File

@ -0,0 +1,2 @@
export * from './ModifiersContext';
export * from './utils/getActiveModifiers';

View File

@ -0,0 +1,53 @@
import { addMonths } from 'date-fns';
import {
InternalModifier,
InternalModifiers,
Modifiers
} from 'types/Modifiers';
import { getActiveModifiers } from './getActiveModifiers';
const day = new Date();
const internalModifiers: InternalModifiers = {
[InternalModifier.Outside]: [],
[InternalModifier.Disabled]: [],
[InternalModifier.Selected]: [],
[InternalModifier.Hidden]: [],
[InternalModifier.Today]: [],
[InternalModifier.RangeStart]: [],
[InternalModifier.RangeEnd]: [],
[InternalModifier.RangeMiddle]: []
};
describe('when the day matches a modifier', () => {
const modifiers: Modifiers = {
...internalModifiers,
foo: [day]
};
const result = getActiveModifiers(day, modifiers);
test('should return the modifier as active', () => {
expect(result.foo).toBe(true);
});
});
describe('when the day does not match a modifier', () => {
const modifiers: Modifiers = {
...internalModifiers,
foo: []
};
const result = getActiveModifiers(day, modifiers);
test('should not return the modifier as active', () => {
expect(result.foo).toBeUndefined();
});
});
describe('when the day is not in the same display month', () => {
const modifiers: Modifiers = {
...internalModifiers
};
const displayMonth = addMonths(day, 1);
const result = getActiveModifiers(day, modifiers, displayMonth);
test('should not return the modifier as active', () => {
expect(result.outside).toBe(true);
});
});

View File

@ -0,0 +1,33 @@
import { isSameMonth } from 'date-fns';
import { ActiveModifiers, Modifiers } from 'types/Modifiers';
import { isMatch } from './isMatch';
/** Return the active modifiers for the given day. */
export function getActiveModifiers(
day: Date,
/** The modifiers to match for the given date. */
modifiers: Modifiers,
/** The month where the day is displayed, to add the "outside" modifiers. */
displayMonth?: Date
): ActiveModifiers {
const matchedModifiers = Object.keys(modifiers).reduce(
(result: string[], key: string): string[] => {
const modifier = modifiers[key];
if (isMatch(day, modifier)) {
result.push(key);
}
return result;
},
[]
);
const activeModifiers: ActiveModifiers = {};
matchedModifiers.forEach((modifier) => (activeModifiers[modifier] = true));
if (displayMonth && !isSameMonth(day, displayMonth)) {
activeModifiers.outside = true;
}
return activeModifiers;
}

View File

@ -0,0 +1,14 @@
import { DayModifiers } from 'index';
import { getCustomModifiers } from './getCustomModifiers';
describe('when some modifiers are not an array', () => {
const date = new Date();
const dayModifiers: DayModifiers = {
foo: date
};
const result = getCustomModifiers(dayModifiers);
test('should return as array', () => {
expect(result.foo).toEqual([date]);
});
});

View File

@ -0,0 +1,14 @@
import { CustomModifiers, DayModifiers } from 'types/Modifiers';
import { matcherToArray } from './matcherToArray';
/** Create CustomModifiers from dayModifiers */
export function getCustomModifiers(
dayModifiers: DayModifiers
): CustomModifiers {
const customModifiers: CustomModifiers = {};
Object.entries(dayModifiers).forEach(([modifier, matcher]) => {
customModifiers[modifier] = matcherToArray(matcher);
});
return customModifiers;
}

View File

@ -0,0 +1,147 @@
import { addDays } from 'date-fns';
import { DayPickerContextValue } from 'contexts/DayPicker';
import { getDefaultContextValues } from 'contexts/DayPicker/defaultContextValues';
import { SelectRangeContextValue } from 'contexts/SelectRange';
import { InternalModifier, InternalModifiers } from 'types/Modifiers';
import { getInternalModifiers } from './getInternalModifiers';
const defaultDayPickerContext: DayPickerContextValue =
getDefaultContextValues();
const defaultSelectMultipleContext = {
selected: undefined,
modifiers: { disabled: [] }
};
const defaultSelectRangeContext = {
selected: undefined,
modifiers: {
disabled: [],
range_start: [],
range_end: [],
range_middle: []
}
};
const { Selected, Disabled, Hidden, Today, RangeEnd, RangeMiddle, RangeStart } =
InternalModifier;
const internalModifiers = [Selected, Disabled, Hidden, Today];
test.each(internalModifiers)(
'should transform to array the modifiers from the "%s" prop',
(propName) => {
const value = new Date();
const modifiers = getInternalModifiers(
{ ...defaultDayPickerContext, [propName]: value },
defaultSelectMultipleContext,
defaultSelectRangeContext
);
expect(modifiers[propName]).toStrictEqual([value]);
}
);
describe('when navigation is limited by "fromDate"', () => {
const fromDate = new Date();
const dayPickerContext: DayPickerContextValue = {
...defaultDayPickerContext,
fromDate
};
test('should add a "before" matcher to the "disabled" modifiers', () => {
const modifiers = getInternalModifiers(
dayPickerContext,
defaultSelectMultipleContext,
defaultSelectRangeContext
);
expect(modifiers.disabled).toStrictEqual([{ before: fromDate }]);
});
});
describe('when navigation is limited by "toDate"', () => {
const toDate = new Date();
const dayPickerContext: DayPickerContextValue = {
...defaultDayPickerContext,
toDate
};
test('should add an "after" matcher to the "disabled" modifiers', () => {
const modifiers = getInternalModifiers(
dayPickerContext,
defaultSelectMultipleContext,
defaultSelectRangeContext
);
expect(modifiers.disabled).toStrictEqual([{ after: toDate }]);
});
});
describe('when in multiple select mode', () => {
const disabledDate = new Date();
const dayPickerContext: DayPickerContextValue = {
...defaultDayPickerContext,
mode: 'multiple'
};
const selectMultipleContext = {
...defaultSelectMultipleContext,
modifiers: {
[Disabled]: [disabledDate]
}
};
test('should add the disabled modifier from the select multiple context', () => {
const modifiers = getInternalModifiers(
dayPickerContext,
selectMultipleContext,
defaultSelectRangeContext
);
expect(modifiers.disabled).toStrictEqual([disabledDate]);
});
});
describe('when in range select mode', () => {
const disabled = [new Date()];
const rangeStart = new Date();
const rangeMiddle = [addDays(rangeStart, 1), addDays(rangeStart, 2)];
const rangeEnd = [addDays(rangeStart, 3)];
const dayPickerContext: DayPickerContextValue = {
...defaultDayPickerContext,
mode: 'range'
};
const selectRangeContext: SelectRangeContextValue = {
...defaultSelectRangeContext,
modifiers: {
[Disabled]: [disabled],
[RangeStart]: [rangeStart],
[RangeEnd]: rangeEnd,
[RangeMiddle]: rangeMiddle
}
};
let internalModifiers: InternalModifiers;
beforeEach(() => {
internalModifiers = getInternalModifiers(
dayPickerContext,
defaultSelectMultipleContext,
selectRangeContext
);
});
test('should add the Disabled modifier from the SelectRange context', () => {
expect(internalModifiers[Disabled]).toStrictEqual(
selectRangeContext.modifiers[Disabled]
);
});
test('should add the RangeStart modifier from the SelectRange context', () => {
expect(internalModifiers[RangeStart]).toStrictEqual(
selectRangeContext.modifiers[RangeStart]
);
});
test('should add the RangeEnd modifier from the SelectRange context', () => {
expect(internalModifiers[RangeEnd]).toStrictEqual(
selectRangeContext.modifiers[RangeEnd]
);
});
test('should add the RangeMiddle modifier from the SelectRange context', () => {
expect(internalModifiers[RangeMiddle]).toStrictEqual(
selectRangeContext.modifiers[RangeMiddle]
);
});
});

View File

@ -0,0 +1,58 @@
import { DayPickerContextValue } from 'contexts/DayPicker';
import { SelectMultipleContextValue } from 'contexts/SelectMultiple';
import { SelectRangeContextValue } from 'contexts/SelectRange';
import { isDayPickerMultiple } from 'types/DayPickerMultiple';
import { isDayPickerRange } from 'types/DayPickerRange';
import { InternalModifier, InternalModifiers } from 'types/Modifiers';
import { matcherToArray } from './matcherToArray';
const {
Selected,
Disabled,
Hidden,
Today,
RangeEnd,
RangeMiddle,
RangeStart,
Outside
} = InternalModifier;
/** Return the {@link InternalModifiers} from the DayPicker and select contexts. */
export function getInternalModifiers(
dayPicker: DayPickerContextValue,
selectMultiple: SelectMultipleContextValue,
selectRange: SelectRangeContextValue
) {
const internalModifiers: InternalModifiers = {
[Selected]: matcherToArray(dayPicker.selected),
[Disabled]: matcherToArray(dayPicker.disabled),
[Hidden]: matcherToArray(dayPicker.hidden),
[Today]: [dayPicker.today],
[RangeEnd]: [],
[RangeMiddle]: [],
[RangeStart]: [],
[Outside]: []
};
if (dayPicker.fromDate) {
internalModifiers[Disabled].push({ before: dayPicker.fromDate });
}
if (dayPicker.toDate) {
internalModifiers[Disabled].push({ after: dayPicker.toDate });
}
if (isDayPickerMultiple(dayPicker)) {
internalModifiers[Disabled] = internalModifiers[Disabled].concat(
selectMultiple.modifiers[Disabled]
);
} else if (isDayPickerRange(dayPicker)) {
internalModifiers[Disabled] = internalModifiers[Disabled].concat(
selectRange.modifiers[Disabled]
);
internalModifiers[RangeStart] = selectRange.modifiers[RangeStart];
internalModifiers[RangeMiddle] = selectRange.modifiers[RangeMiddle];
internalModifiers[RangeEnd] = selectRange.modifiers[RangeEnd];
}
return internalModifiers;
}

View File

@ -0,0 +1,45 @@
import { addDays } from 'date-fns';
import { DateRange } from 'index';
import { isDateInRange } from './isDateInRange';
const date = new Date();
describe('when range is missing the "from" date', () => {
const range: DateRange = { from: undefined };
const result = isDateInRange(date, range);
test('should return false', () => {
expect(result).toBe(false);
});
});
describe('when range is missing the "to" date', () => {
const result = isDateInRange(date, { from: date, to: undefined });
test('should return true', () => {
expect(result).toBe(true);
});
});
describe('when the range dates are the same as date', () => {
const range: DateRange = { from: date, to: date };
const result = isDateInRange(date, range);
test('should return true', () => {
expect(result).toBe(true);
});
});
describe('when the range dates are the same but not as date', () => {
const range: DateRange = { from: date, to: date };
const result = isDateInRange(addDays(date, 1), range);
test('should return false', () => {
expect(result).toBe(false);
});
});
describe('when the range is inverted', () => {
const range: DateRange = { from: addDays(date, 1), to: date };
const result = isDateInRange(date, range);
test('should return true', () => {
expect(result).toBe(true);
});
});

View File

@ -0,0 +1,25 @@
import { differenceInCalendarDays, isSameDay } from 'date-fns';
import { DateRange } from 'types/Matchers';
/** Return `true` whether `date` is inside `range`. */
export function isDateInRange(date: Date, range: DateRange): boolean {
let { from, to } = range;
if (from && to) {
const isRangeInverted = differenceInCalendarDays(to, from) < 0;
if (isRangeInverted) {
[from, to] = [to, from];
}
const isInRange =
differenceInCalendarDays(date, from) >= 0 &&
differenceInCalendarDays(to, date) >= 0;
return isInRange;
}
if (to) {
return isSameDay(to, date);
}
if (from) {
return isSameDay(from, date);
}
return false;
}

View File

@ -0,0 +1,111 @@
import { addDays, subDays } from 'date-fns';
import {
DateAfter,
DateBefore,
DateInterval,
DateRange,
DayOfWeek
} from 'types/Matchers';
import { isMatch } from './isMatch';
const testDay = new Date();
describe('when the matcher is a boolean', () => {
const matcher = true;
const result = isMatch(testDay, [matcher]);
test('should return the boolean', () => {
expect(result).toBe(matcher);
});
});
describe('when matching the same day', () => {
const matcher = testDay;
const result = isMatch(testDay, [matcher]);
test('should return true', () => {
expect(result).toBe(true);
});
});
describe('when matching an array of dates including the day', () => {
const matcher = [addDays(testDay, -1), testDay, addDays(testDay, 1)];
const result = isMatch(testDay, [matcher]);
test('should return true', () => {
expect(result).toBe(true);
});
});
describe('when matching date range', () => {
const matcher: DateRange = {
from: testDay,
to: addDays(testDay, 1)
};
const result = isMatch(testDay, [matcher]);
test('should return true', () => {
expect(result).toBe(true);
});
});
describe('when matching the day of week', () => {
const matcher: DayOfWeek = {
dayOfWeek: [testDay.getDay()]
};
const result = isMatch(testDay, [matcher]);
test('should return true', () => {
expect(result).toBe(true);
});
});
describe('when matching date interval (closed)', () => {
const matcher: DateInterval = {
before: addDays(testDay, 5),
after: subDays(testDay, 3)
};
const result = isMatch(testDay, [matcher]);
test('should return true for the included day', () => {
expect(result).toBe(true);
});
});
describe('when matching date interval (open)', () => {
const matcher: DateInterval = {
before: subDays(testDay, 4),
after: addDays(testDay, 5)
};
test('should return false', () => {
const result = isMatch(testDay, [matcher]);
expect(result).toBe(false);
});
test('should return true for the days before', () => {
const result = isMatch(subDays(testDay, 8), [matcher]);
expect(result).toBe(true);
});
test('should return true for the days after', () => {
const result = isMatch(addDays(testDay, 8), [matcher]);
expect(result).toBe(true);
});
});
describe('when matching the date after', () => {
const matcher: DateAfter = { after: addDays(testDay, -1) };
const result = isMatch(testDay, [matcher]);
test('should return true', () => {
expect(result).toBe(true);
});
});
describe('when matching the date before', () => {
const matcher: DateBefore = { before: addDays(testDay, +1) };
const result = isMatch(testDay, [matcher]);
test('should return true', () => {
expect(result).toBe(true);
});
});
describe('when the matcher is a function', () => {
const matcher = () => true;
const result = isMatch(testDay, [matcher]);
test('should return the result of the function', () => {
expect(result).toBe(true);
});
});

View File

@ -0,0 +1,81 @@
import { differenceInCalendarDays, isAfter, isDate, isSameDay } from 'date-fns';
import {
isDateAfterType,
isDateBeforeType,
isDateInterval,
isDateRange,
isDayOfWeekType,
Matcher
} from 'types/Matchers';
import { isDateInRange } from './isDateInRange';
/** Returns true if `value` is a Date type. */
function isDateType(value: unknown): value is Date {
return isDate(value);
}
/** Returns true if `value` is an array of valid dates. */
function isArrayOfDates(value: unknown): value is Date[] {
return Array.isArray(value) && value.every(isDate);
}
/**
* Returns whether a day matches against at least one of the given Matchers.
*
* ```
* const day = new Date(2022, 5, 19);
* const matcher1: DateRange = {
* from: new Date(2021, 12, 21),
* to: new Date(2021, 12, 30)
* }
* const matcher2: DateRange = {
* from: new Date(2022, 5, 1),
* to: new Date(2022, 5, 23)
* }
*
* const isMatch(day, [matcher1, matcher2]); // true, since day is in the matcher1 range.
* ```
* */
export function isMatch(day: Date, matchers: Matcher[]): boolean {
return matchers.some((matcher: Matcher) => {
if (typeof matcher === 'boolean') {
return matcher;
}
if (isDateType(matcher)) {
return isSameDay(day, matcher);
}
if (isArrayOfDates(matcher)) {
return matcher.includes(day);
}
if (isDateRange(matcher)) {
return isDateInRange(day, matcher);
}
if (isDayOfWeekType(matcher)) {
return matcher.dayOfWeek.includes(day.getDay());
}
if (isDateInterval(matcher)) {
const diffBefore = differenceInCalendarDays(matcher.before, day);
const diffAfter = differenceInCalendarDays(matcher.after, day);
const isDayBefore = diffBefore > 0;
const isDayAfter = diffAfter < 0;
const isClosedInterval = isAfter(matcher.before, matcher.after);
if (isClosedInterval) {
return isDayAfter && isDayBefore;
} else {
return isDayBefore || isDayAfter;
}
}
if (isDateAfterType(matcher)) {
return differenceInCalendarDays(day, matcher.after) > 0;
}
if (isDateBeforeType(matcher)) {
return differenceInCalendarDays(matcher.before, day) > 0;
}
if (typeof matcher === 'function') {
return matcher(day);
}
return false;
});
}

View File

@ -0,0 +1,25 @@
import { matcherToArray } from 'contexts/Modifiers/utils/matcherToArray';
import { Matcher } from 'types/Matchers';
const matcher: Matcher = jest.fn();
describe('when a Matcher is passed in', () => {
test('should return an array with the Matcher', () => {
expect(matcherToArray(matcher)).toStrictEqual([matcher]);
});
});
describe('when an array of Matchers is passed in', () => {
test('should return a copy of the array', () => {
const value = [matcher, matcher];
const result = matcherToArray(value);
expect(result).toStrictEqual(value);
expect(result).not.toBe(value);
});
});
describe('when undefined is passed in', () => {
test('should return an empty array', () => {
expect(matcherToArray(undefined)).toStrictEqual([]);
});
});

View File

@ -0,0 +1,14 @@
import { Matcher } from 'types/Matchers';
/** Normalize to array a matcher input. */
export function matcherToArray(
matcher: Matcher | Matcher[] | undefined
): Matcher[] {
if (Array.isArray(matcher)) {
return [...matcher];
} else if (matcher !== undefined) {
return [matcher];
} else {
return [];
}
}

View File

@ -0,0 +1,126 @@
import { act } from '@testing-library/react';
import { addMonths, startOfMonth, subMonths } from 'date-fns';
import { DayPickerProps } from 'DayPicker';
import { renderDayPickerHook, RenderHookResult } from 'test/render';
import { freezeBeforeAll } from 'test/utils';
import { NavigationContextValue, useNavigation } from './NavigationContext';
const today = new Date(2021, 11, 8);
const todaysMonth = startOfMonth(today);
freezeBeforeAll(today);
function renderHook(props: Partial<DayPickerProps> = {}) {
return renderDayPickerHook<NavigationContextValue>(useNavigation, props);
}
let result: RenderHookResult<NavigationContextValue>;
describe('when rendered', () => {
beforeEach(() => {
result = renderHook();
});
test('the current month should be the today`s month', () => {
expect(result.current.currentMonth).toEqual(todaysMonth);
});
test('the display months should be the today`s month', () => {
expect(result.current.displayMonths).toEqual([todaysMonth]);
});
test('the previous month should be the month before today`s month', () => {
expect(result.current.previousMonth).toEqual(subMonths(todaysMonth, 1));
});
test('the next month should be the month after today`s month', () => {
expect(result.current.nextMonth).toEqual(addMonths(todaysMonth, 1));
});
describe('when goToMonth is called', () => {
const newMonth = addMonths(todaysMonth, 10);
beforeEach(() => {
result = renderHook();
act(() => result.current.goToMonth(newMonth));
});
test('should go to the specified month', () => {
expect(result.current.currentMonth).toEqual(newMonth);
});
test('the display months should be the today`s month', () => {
expect(result.current.displayMonths).toEqual([newMonth]);
});
test('the previous month should be the month before today`s month', () => {
expect(result.current.previousMonth).toEqual(subMonths(newMonth, 1));
});
test('the next month should be the month after today`s month', () => {
expect(result.current.nextMonth).toEqual(addMonths(newMonth, 1));
});
});
describe('when goToDate is called with a date from another month', () => {
const newDate = addMonths(today, 10);
const onMonthChange = jest.fn();
beforeEach(() => {
result = renderHook({ onMonthChange });
act(() => result.current.goToDate(newDate));
});
test('should go to the specified month', () => {
const date = startOfMonth(newDate);
expect(result.current.currentMonth).toEqual(date);
expect(onMonthChange).toHaveBeenCalledWith(date);
});
});
describe('when isDateDisplayed is called', () => {
describe('with a date in the calendar', () => {
test('should return true', () => {
expect(result.current.isDateDisplayed(today)).toBe(true);
});
});
describe('with a date not in the calendar', () => {
test('should return false', () => {
expect(result.current.isDateDisplayed(addMonths(today, 1))).toBe(false);
});
});
});
});
const numberOfMonths = 2;
describe('when the number of months is ${numberOfMonths}', () => {
beforeEach(() => {
result = renderHook({ numberOfMonths: 2 });
});
test('the current month should be the today`s month', () => {
expect(result.current.currentMonth).toEqual(todaysMonth);
});
test('the display months should be the today`s and next month', () => {
expect(result.current.displayMonths).toEqual([
todaysMonth,
addMonths(todaysMonth, 1)
]);
});
test('the previous month should be the month before today`s month', () => {
expect(result.current.previousMonth).toEqual(subMonths(todaysMonth, 1));
});
test('the next month should be the month after today`s month', () => {
expect(result.current.nextMonth).toEqual(addMonths(todaysMonth, 1));
});
});
describe(`when the number of months is ${numberOfMonths} and the navigation is paged`, () => {
beforeEach(() => {
result = renderHook({ numberOfMonths, pagedNavigation: true });
});
test('the current month should be the today`s month', () => {
expect(result.current.currentMonth).toEqual(todaysMonth);
});
test('the display months should be the today`s and next month', () => {
expect(result.current.displayMonths).toEqual([
todaysMonth,
addMonths(todaysMonth, 1)
]);
});
test(`the previous month should be the ${numberOfMonths} months before today's month`, () => {
expect(result.current.previousMonth).toEqual(
subMonths(todaysMonth, numberOfMonths)
);
});
test(`the next month should be ${numberOfMonths} months after today's month`, () => {
expect(result.current.nextMonth).toEqual(
addMonths(todaysMonth, numberOfMonths)
);
});
});

View File

@ -0,0 +1,95 @@
import { createContext, ReactNode, useContext } from 'react';
import { addMonths, isBefore, isSameMonth } from 'date-fns';
import { useDayPicker } from '../DayPicker';
import { useNavigationState } from './useNavigationState';
import { getDisplayMonths } from './utils/getDisplayMonths';
import { getNextMonth } from './utils/getNextMonth';
import { getPreviousMonth } from './utils/getPreviousMonth';
/** Represents the value of the {@link NavigationContext}. */
export interface NavigationContextValue {
/** The month to display in the calendar. When `numberOfMonths` is greater than one, is the first of the displayed months. */
currentMonth: Date;
/** The months rendered by DayPicker. DayPicker can render multiple months via `numberOfMonths`. */
displayMonths: Date[];
/** Navigate to the specified month. */
goToMonth: (month: Date) => void;
/** Navigate to the specified date. */
goToDate: (date: Date, refDate?: Date) => void;
/** The next month to display. */
nextMonth?: Date;
/** The previous month to display. */
previousMonth?: Date;
/** Whether the given day is included in the displayed months. */
isDateDisplayed: (day: Date) => boolean;
}
/**
* The Navigation context shares details and methods to navigate the months in DayPicker.
* Access this context from the {@link useNavigation} hook.
*/
export const NavigationContext = createContext<
NavigationContextValue | undefined
>(undefined);
/** Provides the values for the {@link NavigationContext}. */
export function NavigationProvider(props: {
children?: ReactNode;
}): JSX.Element {
const dayPicker = useDayPicker();
const [currentMonth, goToMonth] = useNavigationState();
const displayMonths = getDisplayMonths(currentMonth, dayPicker);
const nextMonth = getNextMonth(currentMonth, dayPicker);
const previousMonth = getPreviousMonth(currentMonth, dayPicker);
const isDateDisplayed = (date: Date) => {
return displayMonths.some((displayMonth) =>
isSameMonth(date, displayMonth)
);
};
const goToDate = (date: Date, refDate?: Date) => {
if (isDateDisplayed(date)) {
return;
}
if (refDate && isBefore(date, refDate)) {
goToMonth(addMonths(date, 1 + dayPicker.numberOfMonths * -1));
} else {
goToMonth(date);
}
};
const value: NavigationContextValue = {
currentMonth,
displayMonths,
goToMonth,
goToDate,
previousMonth,
nextMonth,
isDateDisplayed
};
return (
<NavigationContext.Provider value={value}>
{props.children}
</NavigationContext.Provider>
);
}
/**
* Hook to access the {@link NavigationContextValue}. Use this hook to navigate
* between months or years in DayPicker.
*
* This hook is meant to be used inside internal or custom components.
*/
export function useNavigation(): NavigationContextValue {
const context = useContext(NavigationContext);
if (!context) {
throw new Error('useNavigation must be used within a NavigationProvider');
}
return context;
}

View File

@ -0,0 +1 @@
export * from './NavigationContext';

View File

@ -0,0 +1,36 @@
import { act } from '@testing-library/react';
import { addMonths, startOfMonth } from 'date-fns';
import { DayPickerProps } from 'DayPicker';
import { renderDayPickerHook } from 'test/render';
import { freezeBeforeAll } from 'test/utils';
import { NavigationState, useNavigationState } from './useNavigationState';
const today = new Date(2021, 11, 8);
freezeBeforeAll(today);
function renderHook(props: Partial<DayPickerProps> = {}) {
return renderDayPickerHook<NavigationState>(useNavigationState, props);
}
describe('when goToMonth is called', () => {
test('should set the month in state', () => {
const onMonthChange = jest.fn();
const result = renderHook({ onMonthChange });
const month = addMonths(today, 2);
act(() => result.current[1](month));
expect(result.current[0]).toEqual(startOfMonth(month));
expect(onMonthChange).toHaveBeenCalledWith(startOfMonth(month));
});
describe('when navigation is disabled', () => {
test('should not set the month in state', () => {
const onMonthChange = jest.fn();
const result = renderHook({ disableNavigation: true, onMonthChange });
const month = addMonths(today, 2);
result.current[1](month);
expect(result.current[0]).toEqual(startOfMonth(today));
expect(onMonthChange).not.toHaveBeenCalled();
});
});
});

View File

@ -0,0 +1,29 @@
import { startOfMonth } from 'date-fns';
import { useDayPicker } from 'contexts/DayPicker';
import { useControlledValue } from 'hooks/useControlledValue';
import { getInitialMonth } from './utils/getInitialMonth';
export type NavigationState = [
/** The month DayPicker is navigating at */
month: Date,
/** Go to the specified month. */
goToMonth: (month: Date) => void
];
/** Controls the navigation state. */
export function useNavigationState(): NavigationState {
const context = useDayPicker();
const initialMonth = getInitialMonth(context);
const [month, setMonth] = useControlledValue(initialMonth, context.month);
const goToMonth = (date: Date) => {
if (context.disableNavigation) return;
const month = startOfMonth(date);
setMonth(month);
context.onMonthChange?.(month);
};
return [month, goToMonth];
}

View File

@ -0,0 +1,29 @@
import { addMonths, differenceInCalendarMonths, startOfMonth } from 'date-fns';
/**
* Return the months to display in the component according to the number of
* months and the from/to date.
*/
export function getDisplayMonths(
month: Date,
{
reverseMonths,
numberOfMonths
}: {
reverseMonths?: boolean;
numberOfMonths: number;
}
): Date[] {
const start = startOfMonth(month);
const end = startOfMonth(addMonths(start, numberOfMonths));
const monthsDiff = differenceInCalendarMonths(end, start);
let months = [];
for (let i = 0; i < monthsDiff; i++) {
const nextMonth = addMonths(start, i);
months.push(nextMonth);
}
if (reverseMonths) months = months.reverse();
return months;
}

View File

@ -0,0 +1,56 @@
import { addMonths, isSameMonth } from 'date-fns';
import { getInitialMonth } from './getInitialMonth';
describe('when no toDate is given', () => {
describe('when month is in context', () => {
const month = new Date(2010, 11, 12);
it('return that month', () => {
const initialMonth = getInitialMonth({ month });
expect(isSameMonth(initialMonth, month)).toBe(true);
});
});
describe('when defaultMonth is in context', () => {
const defaultMonth = new Date(2010, 11, 12);
it('return that month', () => {
const initialMonth = getInitialMonth({ defaultMonth });
expect(isSameMonth(initialMonth, defaultMonth)).toBe(true);
});
});
describe('when no month or defaultMonth are in context', () => {
const today = new Date(2010, 11, 12);
it('return the today month', () => {
const initialMonth = getInitialMonth({ today });
expect(isSameMonth(initialMonth, today)).toBe(true);
});
});
});
describe('when toDate is given', () => {
describe('when toDate is before the default initial date', () => {
const month = new Date(2010, 11, 12);
const toDate = addMonths(month, -2);
describe('when the number of month is 1', () => {
const numberOfMonths = 1;
it('return the toDate', () => {
const initialMonth = getInitialMonth({
month,
toDate,
numberOfMonths
});
expect(isSameMonth(initialMonth, toDate)).toBe(true);
});
});
describe('when the number of month is 3', () => {
const numberOfMonths = 3;
it('return the toDate plus the number of months', () => {
const initialMonth = getInitialMonth({
month,
toDate,
numberOfMonths
});
const expectedMonth = addMonths(toDate, -1 * (numberOfMonths - 1));
expect(isSameMonth(initialMonth, expectedMonth)).toBe(true);
});
});
});
});

View File

@ -0,0 +1,22 @@
import { addMonths, differenceInCalendarMonths, startOfMonth } from 'date-fns';
import { DayPickerContextValue } from 'contexts/DayPicker';
/** Return the initial month according to the given options. */
export function getInitialMonth(context: Partial<DayPickerContextValue>): Date {
const { month, defaultMonth, today } = context;
let initialMonth = month || defaultMonth || today || new Date();
const { toDate, fromDate, numberOfMonths = 1 } = context;
// Fix the initialMonth if is after the to-date
if (toDate && differenceInCalendarMonths(toDate, initialMonth) < 0) {
const offset = -1 * (numberOfMonths - 1);
initialMonth = addMonths(toDate, offset);
}
// Fix the initialMonth if is before the from-date
if (fromDate && differenceInCalendarMonths(initialMonth, fromDate) < 0) {
initialMonth = fromDate;
}
return startOfMonth(initialMonth);
}

View File

@ -0,0 +1,75 @@
import { addMonths, isSameMonth } from 'date-fns';
import { getNextMonth } from './getNextMonth';
const startingMonth = new Date(2020, 4, 31);
describe('when number of months is 1', () => {
describe('when the navigation is disabled', () => {
const disableNavigation = true;
it('the next month is undefined', () => {
const result = getNextMonth(startingMonth, { disableNavigation });
expect(result).toBe(undefined);
});
});
describe('when in the navigable range', () => {
const toDate = addMonths(startingMonth, 3);
it('the next month is not undefined', () => {
const result = getNextMonth(startingMonth, { toDate });
const expectedNextMonth = addMonths(startingMonth, 1);
expect(result && isSameMonth(result, expectedNextMonth)).toBeTruthy();
});
});
describe('when not in the navigable range', () => {
const toDate = startingMonth;
it('the next month is undefined', () => {
const result = getNextMonth(startingMonth, { toDate });
expect(result).toBe(undefined);
});
});
});
describe('when displaying 3 months', () => {
const numberOfMonths = 3;
describe('when the navigation is paged', () => {
const pagedNavigation = true;
it('the next month is 3 months ahead', () => {
const result = getNextMonth(startingMonth, {
numberOfMonths,
pagedNavigation
});
const expectedNextMonth = addMonths(startingMonth, 3);
expect(result && isSameMonth(result, expectedNextMonth)).toBeTruthy();
});
describe('when the to-date is ahead less than 3 months', () => {
it('the next month is undefined', () => {
const result = getNextMonth(startingMonth, {
numberOfMonths,
pagedNavigation,
toDate: addMonths(startingMonth, 1)
});
expect(result).toBe(undefined);
});
});
});
describe('when the navigation is not paged', () => {
const pagedNavigation = false;
it('the next month is 1 months ahead', () => {
const result = getNextMonth(startingMonth, {
numberOfMonths,
pagedNavigation
});
const expectedNextMonth = addMonths(startingMonth, 1);
expect(result && isSameMonth(result, expectedNextMonth)).toBeTruthy();
});
describe('when the to-date is ahead less than 3 months', () => {
it('the next month is undefined', () => {
const result = getNextMonth(startingMonth, {
numberOfMonths,
pagedNavigation,
toDate: addMonths(startingMonth, 2)
});
expect(result).toBe(undefined);
});
});
});
});

View File

@ -0,0 +1,43 @@
import { addMonths, differenceInCalendarMonths, startOfMonth } from 'date-fns';
/**
* Returns the next month the user can navigate to according to the given
* options.
*
* Please note that the next month is not always the next calendar month:
*
* - if after the `toDate` range, is undefined;
* - if the navigation is paged, is the number of months displayed ahead.
*
*/
export function getNextMonth(
startingMonth: Date,
options: {
numberOfMonths?: number;
fromDate?: Date;
toDate?: Date;
pagedNavigation?: boolean;
today?: Date;
disableNavigation?: boolean;
}
): Date | undefined {
if (options.disableNavigation) {
return undefined;
}
const { toDate, pagedNavigation, numberOfMonths = 1 } = options;
const offset = pagedNavigation ? numberOfMonths : 1;
const month = startOfMonth(startingMonth);
if (!toDate) {
return addMonths(month, offset);
}
const monthsDiff = differenceInCalendarMonths(toDate, startingMonth);
if (monthsDiff < numberOfMonths) {
return undefined;
}
// Jump forward as the number of months when paged navigation
return addMonths(month, offset);
}

View File

@ -0,0 +1,55 @@
import { addMonths, isSameMonth } from 'date-fns';
import { getPreviousMonth } from './getPreviousMonth';
const startingMonth = new Date(2020, 4, 31);
describe('when number of months is 1', () => {
describe('when the navigation is disabled', () => {
const disableNavigation = true;
it('the previous month is undefined', () => {
const result = getPreviousMonth(startingMonth, { disableNavigation });
expect(result).toBe(undefined);
});
});
describe('when in the navigable range', () => {
const fromDate = addMonths(startingMonth, -3);
it('the previous month is not undefined', () => {
const result = getPreviousMonth(startingMonth, { fromDate });
const expectedPrevMonth = addMonths(startingMonth, -1);
expect(result && isSameMonth(result, expectedPrevMonth)).toBeTruthy();
});
});
describe('when not in the navigable range', () => {
const fromDate = startingMonth;
it('the previous month is undefined', () => {
const result = getPreviousMonth(startingMonth, { fromDate });
expect(result).toBe(undefined);
});
});
});
describe('when displaying 3 months', () => {
const numberOfMonths = 3;
describe('when the navigation is paged', () => {
const pagedNavigation = true;
it('the previous month is 3 months back', () => {
const result = getPreviousMonth(startingMonth, {
numberOfMonths,
pagedNavigation
});
const expectedPrevMonth = addMonths(startingMonth, -numberOfMonths);
expect(result && isSameMonth(result, expectedPrevMonth)).toBeTruthy();
});
});
describe('when the navigation is not paged', () => {
const pagedNavigation = false;
it('the previous month is 1 months back', () => {
const result = getPreviousMonth(startingMonth, {
numberOfMonths,
pagedNavigation
});
const expectedPrevMonth = addMonths(startingMonth, -1);
expect(result && isSameMonth(result, expectedPrevMonth)).toBeTruthy();
});
});
});

View File

@ -0,0 +1,42 @@
import { addMonths, differenceInCalendarMonths, startOfMonth } from 'date-fns';
/**
* Returns the next previous the user can navigate to, according to the given
* options.
*
* Please note that the previous month is not always the previous calendar
* month:
*
* - if before the `fromDate` date, is `undefined`;
* - if the navigation is paged, is the number of months displayed before.
*
*/
export function getPreviousMonth(
startingMonth: Date,
options: {
numberOfMonths?: number;
fromDate?: Date;
toDate?: Date;
pagedNavigation?: boolean;
today?: Date;
disableNavigation?: boolean;
}
): Date | undefined {
if (options.disableNavigation) {
return undefined;
}
const { fromDate, pagedNavigation, numberOfMonths = 1 } = options;
const offset = pagedNavigation ? numberOfMonths : 1;
const month = startOfMonth(startingMonth);
if (!fromDate) {
return addMonths(month, -offset);
}
const monthsDiff = differenceInCalendarMonths(month, fromDate);
if (monthsDiff <= 0) {
return undefined;
}
// Jump back as the number of months when paged navigation
return addMonths(month, -offset);
}

View File

@ -0,0 +1,46 @@
import { ReactNode } from 'react';
import { ModifiersProvider } from 'contexts/Modifiers/ModifiersContext';
import { DayPickerProvider } from './DayPicker';
import { FocusProvider } from './Focus';
import { NavigationProvider } from './Navigation';
import { SelectMultipleProvider } from './SelectMultiple';
import { SelectRangeProvider } from './SelectRange';
import { SelectSingleProvider } from './SelectSingle';
import { DayPickerDefaultProps } from 'types/DayPickerDefault';
import { DayPickerSingleProps } from 'types/DayPickerSingle';
import { DayPickerMultipleProps } from 'types/DayPickerMultiple';
import { DayPickerRangeProps } from 'types/DayPickerRange';
type RootContextProps =
| Partial<DayPickerDefaultProps>
| Partial<DayPickerSingleProps>
| Partial<DayPickerMultipleProps>
| Partial<DayPickerRangeProps>;
/** The props of {@link RootProvider}. */
export type RootContext = RootContextProps & {
children?: ReactNode;
};
/** Provide the value for all the context providers. */
export function RootProvider(props: RootContext): JSX.Element {
const { children, ...initialProps } = props;
return (
<DayPickerProvider initialProps={initialProps}>
<NavigationProvider>
<SelectSingleProvider initialProps={initialProps}>
<SelectMultipleProvider initialProps={initialProps}>
<SelectRangeProvider initialProps={initialProps}>
<ModifiersProvider>
<FocusProvider>{children}</FocusProvider>
</ModifiersProvider>
</SelectRangeProvider>
</SelectMultipleProvider>
</SelectSingleProvider>
</NavigationProvider>
</DayPickerProvider>
);
}

View File

@ -0,0 +1,190 @@
import { MouseEvent } from 'react';
import { addDays, addMonths } from 'date-fns';
import { DayPickerProps } from 'DayPicker';
import { renderDayPickerHook } from 'test/render';
import { freezeBeforeAll } from 'test/utils';
import { isMatch } from 'contexts/Modifiers/utils/isMatch';
import { DayPickerMultipleProps } from 'types/DayPickerMultiple';
import { ActiveModifiers } from 'types/Modifiers';
import {
SelectMultipleContextValue,
useSelectMultiple
} from './SelectMultipleContext';
const today = new Date(2021, 11, 8);
freezeBeforeAll(today);
function renderHook(props?: Partial<DayPickerProps>) {
return renderDayPickerHook<SelectMultipleContextValue>(
useSelectMultiple,
props
);
}
describe('when is not a multiple select DayPicker', () => {
const result = renderHook();
test('the selected day should be undefined', () => {
expect(result.current.selected).toBeUndefined();
});
test('the disabled modifiers should be empty', () => {
expect(result.current.selected).toBeUndefined();
});
});
const initialProps: DayPickerMultipleProps = {
mode: 'multiple',
onDayClick: jest.fn(),
onSelect: jest.fn()
};
const selectedDay1 = today;
const selectedDay2 = addDays(today, 1);
const selectedDay3 = addDays(today, 4);
describe('when days are selected', () => {
const selected = [selectedDay1, selectedDay2, selectedDay3];
const dayPickerProps: DayPickerMultipleProps = {
...initialProps,
selected
};
test('it should return the days as selected', () => {
const result = renderHook(dayPickerProps);
expect(result.current.selected).toStrictEqual(selected);
});
describe('when `onDayClick` is called with a not selected day', () => {
const clickedDay = addDays(selectedDay1, -1);
const activeModifiers = {};
const event = {} as MouseEvent;
beforeAll(() => {
const result = renderHook(dayPickerProps);
result.current.onDayClick?.(clickedDay, activeModifiers, event);
});
afterAll(() => {
jest.resetAllMocks();
});
test('should call the `onDayClick` from the DayPicker props', () => {
expect(dayPickerProps.onDayClick).toHaveBeenCalledWith(
clickedDay,
activeModifiers,
event
);
});
test('should call `onSelect` with the clicked day selected', () => {
expect(dayPickerProps.onSelect).toHaveBeenCalledWith(
[...selected, clickedDay],
clickedDay,
activeModifiers,
event
);
});
});
describe('when `onDayClick` is called with a selected day', () => {
const clickedDay = selectedDay1;
const activeModifiers: ActiveModifiers = { selected: true };
beforeAll(() => {
const result = renderHook(dayPickerProps);
result.current.onDayClick?.(clickedDay, activeModifiers, event);
});
afterAll(() => {
jest.resetAllMocks();
});
const event = {} as MouseEvent;
test('should call the `onDayClick` from the DayPicker props', () => {
expect(dayPickerProps.onDayClick).toHaveBeenCalledWith(
clickedDay,
activeModifiers,
event
);
});
test('should call `onSelect` without the clicked day selected', () => {
const expectedSelected = selected.filter((day) => day !== clickedDay);
expect(dayPickerProps.onSelect).toHaveBeenCalledWith(
expectedSelected,
clickedDay,
activeModifiers,
event
);
});
});
});
describe('when the maximum number of days are selected', () => {
const selected = [selectedDay1, selectedDay2, selectedDay3];
const dayPickerProps: DayPickerMultipleProps = {
...initialProps,
selected,
max: selected.length
};
test('the selected days should not be disabled', () => {
const result = renderHook(dayPickerProps);
const { disabled } = result.current.modifiers;
expect(isMatch(selectedDay1, disabled)).toBe(false);
expect(isMatch(selectedDay2, disabled)).toBe(false);
expect(isMatch(selectedDay3, disabled)).toBe(false);
});
test('the other days should be disabled', () => {
const result = renderHook(dayPickerProps);
const { disabled } = result.current.modifiers;
expect(isMatch(addMonths(selectedDay1, 1), disabled)).toBe(true);
expect(isMatch(addMonths(selectedDay2, 1), disabled)).toBe(true);
});
describe('when `onDayClick` is called', () => {
const clickedDay = addMonths(selectedDay1, 1);
const activeModifiers: ActiveModifiers = {};
beforeAll(() => {
const result = renderHook(dayPickerProps);
result.current.onDayClick?.(clickedDay, activeModifiers, event);
});
afterAll(() => {
jest.resetAllMocks();
});
const event = {} as MouseEvent;
test('should call the `onDayClick` from the DayPicker props', () => {
expect(dayPickerProps.onDayClick).toHaveBeenCalledWith(
clickedDay,
activeModifiers,
event
);
});
test('should not call `onSelect`', () => {
expect(dayPickerProps.onSelect).not.toHaveBeenCalled();
});
});
});
describe('when the minimum number of days are selected', () => {
const selected = [selectedDay1, selectedDay2, selectedDay3];
const dayPickerProps: DayPickerMultipleProps = {
...initialProps,
selected,
min: selected.length
};
describe('when `onDayClick` is called with one of the selected days', () => {
const clickedDay = selected[0];
const activeModifiers: ActiveModifiers = { selected: true };
beforeAll(() => {
const result = renderHook(dayPickerProps);
result.current.onDayClick?.(clickedDay, activeModifiers, event);
});
afterAll(() => {
jest.resetAllMocks();
});
const event = {} as MouseEvent;
test('should call the `onDayClick` from the DayPicker props', () => {
expect(dayPickerProps.onDayClick).toHaveBeenCalledWith(
clickedDay,
activeModifiers,
event
);
});
test('should not call `onSelect`', () => {
expect(dayPickerProps.onSelect).not.toHaveBeenCalled();
});
});
});

View File

@ -0,0 +1,151 @@
import { createContext, ReactNode, useContext } from 'react';
import { isSameDay } from 'date-fns';
import { DayPickerBase } from 'types/DayPickerBase';
import {
DayPickerMultipleProps,
isDayPickerMultiple
} from 'types/DayPickerMultiple';
import { DayClickEventHandler } from 'types/EventHandlers';
import { InternalModifier, Modifiers } from 'types/Modifiers';
/** Represent the modifiers that are changed by the multiple selection. */
export type SelectMultipleModifiers = Pick<
Modifiers,
InternalModifier.Disabled
>;
/** Represents the value of a {@link SelectMultipleContext}. */
export interface SelectMultipleContextValue {
/** The days that have been selected. */
selected: Date[] | undefined;
/** The modifiers for the corresponding selection. */
modifiers: SelectMultipleModifiers;
/** Event handler to attach to the day button to enable the multiple select. */
onDayClick?: DayClickEventHandler;
}
/**
* The SelectMultiple context shares details about the selected days when in
* multiple selection mode.
*
* Access this context from the {@link useSelectMultiple} hook.
*/
export const SelectMultipleContext = createContext<
SelectMultipleContextValue | undefined
>(undefined);
export type SelectMultipleProviderProps = {
initialProps: DayPickerBase;
children?: ReactNode;
};
/** Provides the values for the {@link SelectMultipleContext}. */
export function SelectMultipleProvider(
props: SelectMultipleProviderProps
): JSX.Element {
if (!isDayPickerMultiple(props.initialProps)) {
const emptyContextValue: SelectMultipleContextValue = {
selected: undefined,
modifiers: {
disabled: []
}
};
return (
<SelectMultipleContext.Provider value={emptyContextValue}>
{props.children}
</SelectMultipleContext.Provider>
);
}
return (
<SelectMultipleProviderInternal
initialProps={props.initialProps}
children={props.children}
/>
);
}
/** @private */
export interface SelectMultipleProviderInternalProps {
initialProps: DayPickerMultipleProps;
children?: ReactNode;
}
export function SelectMultipleProviderInternal({
initialProps,
children
}: SelectMultipleProviderInternalProps): JSX.Element {
const { selected, min, max } = initialProps;
const onDayClick: DayClickEventHandler = (day, activeModifiers, e) => {
initialProps.onDayClick?.(day, activeModifiers, e);
const isMinSelected = Boolean(
activeModifiers.selected && min && selected?.length === min
);
if (isMinSelected) {
return;
}
const isMaxSelected = Boolean(
!activeModifiers.selected && max && selected?.length === max
);
if (isMaxSelected) {
return;
}
const selectedDays = selected ? [...selected] : [];
if (activeModifiers.selected) {
const index = selectedDays.findIndex((selectedDay) =>
isSameDay(day, selectedDay)
);
selectedDays.splice(index, 1);
} else {
selectedDays.push(day);
}
initialProps.onSelect?.(selectedDays, day, activeModifiers, e);
};
const modifiers: SelectMultipleModifiers = {
disabled: []
};
if (selected) {
modifiers.disabled.push((day: Date) => {
const isMaxSelected = max && selected.length > max - 1;
const isSelected = selected.some((selectedDay) =>
isSameDay(selectedDay, day)
);
return Boolean(isMaxSelected && !isSelected);
});
}
const contextValue = {
selected,
onDayClick,
modifiers
};
return (
<SelectMultipleContext.Provider value={contextValue}>
{children}
</SelectMultipleContext.Provider>
);
}
/**
* Hook to access the {@link SelectMultipleContextValue}.
*
* This hook is meant to be used inside internal or custom components.
*/
export function useSelectMultiple(): SelectMultipleContextValue {
const context = useContext(SelectMultipleContext);
if (!context) {
throw new Error(
'useSelectMultiple must be used within a SelectMultipleProvider'
);
}
return context;
}

View File

@ -0,0 +1 @@
export * from './SelectMultipleContext';

View File

@ -0,0 +1,302 @@
import { MouseEvent } from 'react';
import {
addDays,
addMonths,
differenceInCalendarDays,
subDays
} from 'date-fns';
import { DayPickerProps } from 'DayPicker';
import { renderDayPickerHook } from 'test/render';
import { freezeBeforeAll } from 'test/utils';
import { isMatch } from 'contexts/Modifiers/utils/isMatch';
import { DayPickerRangeProps } from 'types/DayPickerRange';
import { ActiveModifiers } from 'types/Modifiers';
import { SelectRangeContextValue, useSelectRange } from './SelectRangeContext';
const today = new Date(2021, 11, 8);
freezeBeforeAll(today);
function renderHook(props?: Partial<DayPickerProps>) {
return renderDayPickerHook<SelectRangeContextValue>(useSelectRange, props);
}
describe('when is not a multiple select DayPicker', () => {
test('the selected day should be undefined', () => {
const result = renderHook();
expect(result.current.selected).toBeUndefined();
});
});
const initialProps: DayPickerRangeProps = {
mode: 'range',
onDayClick: jest.fn(),
onSelect: jest.fn()
};
const from = today;
const to = addDays(today, 6);
const stubEvent = {} as MouseEvent;
describe('when no days are selected', () => {
test('the selected days should be undefined', () => {
const result = renderHook();
expect(result.current.selected).toBeUndefined();
});
describe('when "onDayClick" is called', () => {
const day = from;
const activeModifiers = {};
beforeAll(() => {
const result = renderHook(initialProps);
result.current.onDayClick?.(day, activeModifiers, stubEvent);
});
afterAll(() => {
jest.resetAllMocks();
});
test('should call the "onDayClick" from the DayPicker props', () => {
expect(initialProps.onDayClick).toHaveBeenCalledWith(
day,
activeModifiers,
stubEvent
);
});
test('should call "onSelect" with the clicked day as the "from" prop', () => {
expect(initialProps.onSelect).toHaveBeenCalledWith(
{ from: day, to: undefined },
day,
activeModifiers,
stubEvent
);
});
});
});
describe('when only the "from" day is selected', () => {
const selected = { from, to: undefined };
const dayPickerProps: DayPickerRangeProps = {
...initialProps,
selected
};
test('should return the "range_start" modifiers with the "from" day', () => {
const result = renderHook(dayPickerProps);
expect(result.current.modifiers.range_start).toEqual([from]);
});
test('should return the "range_end" modifiers with the "from" day', () => {
const result = renderHook(dayPickerProps);
expect(result.current.modifiers.range_end).toEqual([from]);
});
test('should not return any "range_middle" modifiers', () => {
const result = renderHook(dayPickerProps);
expect(result.current.modifiers.range_middle).toEqual([]);
});
});
describe('when only the "to" day is selected', () => {
const selected = { from: undefined, to };
const dayPickerProps: DayPickerRangeProps = {
...initialProps,
selected
};
test('should return the "range_start" modifiers with the "to" day', () => {
const result = renderHook(dayPickerProps);
expect(result.current.modifiers.range_start).toEqual([to]);
});
test('should return the "range_end" modifiers with the "to" day', () => {
const result = renderHook(dayPickerProps);
expect(result.current.modifiers.range_end).toEqual([to]);
});
test('should not return any "range_middle" modifiers', () => {
const result = renderHook(dayPickerProps);
expect(result.current.modifiers.range_middle).toEqual([]);
});
});
describe('when a complete range of days is selected', () => {
const selected = { from, to };
const dayPickerProps: DayPickerRangeProps = {
...initialProps,
selected
};
test('should return the "range_start" modifiers with the "from" day', () => {
const result = renderHook(dayPickerProps);
expect(result.current.modifiers.range_start).toEqual([from]);
});
test('should return the "range_end" modifiers with the "to" day', () => {
const result = renderHook(dayPickerProps);
expect(result.current.modifiers.range_end).toEqual([to]);
});
test('should return the "range_middle" range modifiers', () => {
const result = renderHook(dayPickerProps);
expect(result.current.modifiers.range_middle).toEqual([
{ after: from, before: to }
]);
});
describe('when "onDayClick" is called with the day before the from day', () => {
const day = addDays(from, -1);
const activeModifiers = {};
beforeAll(() => {
const result = renderHook(dayPickerProps);
result.current.onDayClick?.(day, activeModifiers, stubEvent);
});
afterAll(() => {
jest.resetAllMocks();
});
test('should call the "onDayClick" from the DayPicker props', () => {
expect(dayPickerProps.onDayClick).toHaveBeenCalledWith(
day,
activeModifiers,
stubEvent
);
});
test('should call "onSelect" with the day selected', () => {
expect(dayPickerProps.onSelect).toHaveBeenCalledWith(
{ from: day, to },
day,
activeModifiers,
stubEvent
);
});
});
});
describe('when "from" and "to" are the same', () => {
const date = new Date();
const selected = { from: date, to: date };
const dayPickerProps: DayPickerRangeProps = {
...initialProps,
selected
};
test('should return the "range_start" modifier with the date', () => {
const result = renderHook(dayPickerProps);
expect(result.current.modifiers.range_start).toEqual([date]);
});
test('should return the "range_end" modifier with the date', () => {
const result = renderHook(dayPickerProps);
expect(result.current.modifiers.range_end).toEqual([date]);
});
test('should return an empty "range_middle"', () => {
const result = renderHook(dayPickerProps);
expect(result.current.modifiers.range_middle).toEqual([]);
});
});
describe('when the max number of the selected days is reached', () => {
const from = today;
const to = addDays(today, 6);
const selected = { from, to };
const dayPickerProps: DayPickerRangeProps = {
...initialProps,
selected,
max: 7
};
test('the days in the range should not be disabled', () => {
const result = renderHook(dayPickerProps);
const { disabled } = result.current.modifiers;
expect(isMatch(from, disabled)).toBe(false);
expect(isMatch(to, disabled)).toBe(false);
});
test('the other days should be disabled', () => {
const result = renderHook(dayPickerProps);
const { disabled } = result.current.modifiers;
expect(isMatch(addMonths(from, 1), disabled)).toBe(true);
});
describe('when "onDayClick" is called with a new day', () => {
const day = addMonths(from, 1);
const activeModifiers: ActiveModifiers = {};
beforeAll(() => {
const result = renderHook(dayPickerProps);
result.current.onDayClick?.(day, activeModifiers, stubEvent);
});
afterAll(() => {
jest.resetAllMocks();
});
test('should call the "onDayClick" from the DayPicker props', () => {
expect(dayPickerProps.onDayClick).toHaveBeenCalledWith(
day,
activeModifiers,
stubEvent
);
});
});
});
describe('when the minimum number of days are selected', () => {
const selected = { from, to };
const dayPickerProps: DayPickerRangeProps = {
...initialProps,
selected,
min: Math.abs(differenceInCalendarDays(to, from))
};
describe('when "onDayClick" is called with a day before "from"', () => {
const day = subDays(from, 1);
const activeModifiers: ActiveModifiers = { selected: true };
beforeAll(() => {
const result = renderHook(dayPickerProps);
result.current.onDayClick?.(day, activeModifiers, stubEvent);
});
afterAll(() => {
jest.resetAllMocks();
});
test('should call "onSelect" with the day included in the range', () => {
expect(dayPickerProps.onSelect).toHaveBeenCalledWith(
{ from: day, to },
day,
activeModifiers,
stubEvent
);
});
});
describe('when "onDayClick" is called with the "from" day', () => {
const day = from;
const activeModifiers: ActiveModifiers = { selected: true };
beforeAll(() => {
const result = renderHook(dayPickerProps);
result.current.onDayClick?.(day, activeModifiers, stubEvent);
});
afterAll(() => {
jest.resetAllMocks();
});
test('should call the "onDayClick" from the DayPicker props', () => {
expect(dayPickerProps.onDayClick).toHaveBeenCalledWith(
day,
activeModifiers,
stubEvent
);
});
test('should call "onSelect" with an undefined range', () => {
expect(dayPickerProps.onSelect).toHaveBeenCalledWith(
undefined,
day,
activeModifiers,
stubEvent
);
});
});
describe('when "onDayClick" is called with the "to" day', () => {
const day = to;
const activeModifiers: ActiveModifiers = { selected: true };
beforeAll(() => {
const result = renderHook(dayPickerProps);
result.current.onDayClick?.(day, activeModifiers, stubEvent);
});
afterAll(() => {
jest.resetAllMocks();
});
test('should call "onSelect" without the "to" in the range', () => {
expect(dayPickerProps.onSelect).toHaveBeenCalledWith(
{ from: day, to: undefined },
day,
activeModifiers,
stubEvent
);
});
});
});

View File

@ -0,0 +1,196 @@
import { createContext, ReactNode, useContext } from 'react';
import {
addDays,
differenceInCalendarDays,
isSameDay,
subDays
} from 'date-fns';
import { DayPickerBase } from 'types/DayPickerBase';
import { DayPickerRangeProps, isDayPickerRange } from 'types/DayPickerRange';
import { DayClickEventHandler } from 'types/EventHandlers';
import { DateRange } from 'types/Matchers';
import { InternalModifier, Modifiers } from 'types/Modifiers';
import { addToRange } from './utils/addToRange';
/** Represent the modifiers that are changed by the range selection. */
export type SelectRangeModifiers = Pick<
Modifiers,
| InternalModifier.Disabled
| InternalModifier.RangeEnd
| InternalModifier.RangeMiddle
| InternalModifier.RangeStart
>;
/** Represents the value of a {@link SelectRangeContext}. */
export interface SelectRangeContextValue {
/** The range of days that has been selected. */
selected: DateRange | undefined;
/** The modifiers for the corresponding selection. */
modifiers: SelectRangeModifiers;
/** Event handler to attach to the day button to enable the range select. */
onDayClick?: DayClickEventHandler;
}
/**
* The SelectRange context shares details about the selected days when in
* range selection mode.
*
* Access this context from the {@link useSelectRange} hook.
*/
export const SelectRangeContext = createContext<
SelectRangeContextValue | undefined
>(undefined);
export interface SelectRangeProviderProps {
initialProps: DayPickerBase;
children?: ReactNode;
}
/** Provides the values for the {@link SelectRangeProvider}. */
export function SelectRangeProvider(
props: SelectRangeProviderProps
): JSX.Element {
if (!isDayPickerRange(props.initialProps)) {
const emptyContextValue: SelectRangeContextValue = {
selected: undefined,
modifiers: {
range_start: [],
range_end: [],
range_middle: [],
disabled: []
}
};
return (
<SelectRangeContext.Provider value={emptyContextValue}>
{props.children}
</SelectRangeContext.Provider>
);
}
return (
<SelectRangeProviderInternal
initialProps={props.initialProps}
children={props.children}
/>
);
}
/** @private */
export interface SelectRangeProviderInternalProps {
initialProps: DayPickerRangeProps;
children?: ReactNode;
}
export function SelectRangeProviderInternal({
initialProps,
children
}: SelectRangeProviderInternalProps): JSX.Element {
const { selected } = initialProps;
const { from: selectedFrom, to: selectedTo } = selected || {};
const min = initialProps.min;
const max = initialProps.max;
const onDayClick: DayClickEventHandler = (day, activeModifiers, e) => {
initialProps.onDayClick?.(day, activeModifiers, e);
const newRange = addToRange(day, selected);
initialProps.onSelect?.(newRange, day, activeModifiers, e);
};
const modifiers: SelectRangeModifiers = {
range_start: [],
range_end: [],
range_middle: [],
disabled: []
};
if (selectedFrom) {
modifiers.range_start = [selectedFrom];
if (!selectedTo) {
modifiers.range_end = [selectedFrom];
} else {
modifiers.range_end = [selectedTo];
if (!isSameDay(selectedFrom, selectedTo)) {
modifiers.range_middle = [
{
after: selectedFrom,
before: selectedTo
}
];
}
}
} else if (selectedTo) {
modifiers.range_start = [selectedTo];
modifiers.range_end = [selectedTo];
}
if (min) {
if (selectedFrom && !selectedTo) {
modifiers.disabled.push({
after: subDays(selectedFrom, min - 1),
before: addDays(selectedFrom, min - 1)
});
}
if (selectedFrom && selectedTo) {
modifiers.disabled.push({
after: selectedFrom,
before: addDays(selectedFrom, min - 1)
});
}
if (!selectedFrom && selectedTo) {
modifiers.disabled.push({
after: subDays(selectedTo, min - 1),
before: addDays(selectedTo, min - 1)
});
}
}
if (max) {
if (selectedFrom && !selectedTo) {
modifiers.disabled.push({
before: addDays(selectedFrom, -max + 1)
});
modifiers.disabled.push({
after: addDays(selectedFrom, max - 1)
});
}
if (selectedFrom && selectedTo) {
const selectedCount =
differenceInCalendarDays(selectedTo, selectedFrom) + 1;
const offset = max - selectedCount;
modifiers.disabled.push({
before: subDays(selectedFrom, offset)
});
modifiers.disabled.push({
after: addDays(selectedTo, offset)
});
}
if (!selectedFrom && selectedTo) {
modifiers.disabled.push({
before: addDays(selectedTo, -max + 1)
});
modifiers.disabled.push({
after: addDays(selectedTo, max - 1)
});
}
}
return (
<SelectRangeContext.Provider value={{ selected, onDayClick, modifiers }}>
{children}
</SelectRangeContext.Provider>
);
}
/**
* Hook to access the {@link SelectRangeContextValue}.
*
* This hook is meant to be used inside internal or custom components.
*/
export function useSelectRange(): SelectRangeContextValue {
const context = useContext(SelectRangeContext);
if (!context) {
throw new Error('useSelectRange must be used within a SelectRangeProvider');
}
return context;
}

View File

@ -0,0 +1 @@
export * from './SelectRangeContext';

View File

@ -0,0 +1,119 @@
import { addDays, subDays } from 'date-fns';
import { DateRange } from 'types/Matchers';
import { addToRange } from './addToRange';
describe('when no "from" is the range', () => {
const range = { from: undefined };
const day = new Date();
let result: DateRange | undefined;
beforeAll(() => {
result = addToRange(day, range);
});
test('should set "from" as the given day', () => {
expect(result).toEqual({ from: day, to: undefined });
});
});
describe('when no "to" is the range', () => {
const day = new Date();
const range = { from: day, to: undefined };
describe('and the day is the same as the "from" day', () => {
let result: DateRange | undefined;
beforeAll(() => {
result = addToRange(day, range);
});
test('should return it in the range', () => {
expect(result).toEqual({ from: day, to: day });
});
});
describe('and the day is before "from" day', () => {
const day = subDays(range.from, 1);
let result: DateRange | undefined;
beforeAll(() => {
result = addToRange(day, range);
});
test('should set the day as the "from" range', () => {
expect(result).toEqual({ from: day, to: range.from });
});
});
describe('and the day is after the "from" day', () => {
const day = addDays(range.from, 1);
let result: DateRange | undefined;
beforeAll(() => {
result = addToRange(day, range);
});
test('should set the day as the "to" date', () => {
expect(result).toEqual({ from: range.from, to: day });
});
});
});
describe('when "from", "to" and "day" are the same', () => {
const day = new Date();
const range = { from: day, to: day };
let result: DateRange | undefined;
beforeAll(() => {
result = addToRange(day, range);
});
test('should return an undefined range (reset)', () => {
expect(result).toBeUndefined();
});
});
describe('when "to" and "day" are the same', () => {
const from = new Date();
const to = addDays(from, 4);
const day = to;
const range = { from, to };
let result: DateRange | undefined;
beforeAll(() => {
result = addToRange(day, range);
});
test('should set "to" to undefined', () => {
expect(result).toEqual({ from: to, to: undefined });
});
});
describe('when "from" and "day" are the same', () => {
const from = new Date();
const to = addDays(from, 4);
const day = from;
const range = { from, to };
let result: DateRange | undefined;
beforeAll(() => {
result = addToRange(day, range);
});
test('should return an undefined range (reset)', () => {
expect(result).toBeUndefined();
});
});
describe('when "from" is after "day"', () => {
const day = new Date();
const from = addDays(day, 1);
const to = addDays(from, 4);
const range = { from, to };
let result: DateRange | undefined;
beforeAll(() => {
result = addToRange(day, range);
});
test('should set the day as "from"', () => {
expect(result).toEqual({ from: day, to: range.to });
});
});
describe('when "from" is before "day"', () => {
const day = new Date();
const from = subDays(day, 1);
const to = addDays(from, 4);
const range = { from, to };
let result: DateRange | undefined;
beforeAll(() => {
result = addToRange(day, range);
});
test('should set the day as "to"', () => {
expect(result).toEqual({ from: range.from, to: day });
});
});

View File

@ -0,0 +1,44 @@
import { isAfter, isBefore, isSameDay } from 'date-fns';
import { DateRange } from 'types/Matchers';
/**
* Add a day to an existing range.
*
* The returned range takes in account the `undefined` values and if the added
* day is already present in the range.
*/
export function addToRange(
day: Date,
range?: DateRange
): DateRange | undefined {
const { from, to } = range || {};
if (from && to) {
if (isSameDay(to, day) && isSameDay(from, day)) {
return undefined;
}
if (isSameDay(to, day)) {
return { from: to, to: undefined };
}
if (isSameDay(from, day)) {
return undefined;
}
if (isAfter(from, day)) {
return { from: day, to };
}
return { from, to: day };
}
if (to) {
if (isAfter(day, to)) {
return { from: to, to: day };
}
return { from: day, to };
}
if (from) {
if (isBefore(day, from)) {
return { from: day, to: from };
}
return { from, to: day };
}
return { from: day, to: undefined };
}

View File

@ -0,0 +1,84 @@
import { MouseEvent } from 'react';
import { DayPickerProps } from 'DayPicker';
import { renderDayPickerHook } from 'test/render';
import { freezeBeforeAll } from 'test/utils';
import { DayPickerSingleProps } from 'types/DayPickerSingle';
import { ActiveModifiers } from 'types/Modifiers';
import {
SelectSingleContextValue,
useSelectSingle
} from './SelectSingleContext';
const today = new Date(2021, 11, 8);
freezeBeforeAll(today);
function renderHook(props?: Partial<DayPickerProps>) {
return renderDayPickerHook<SelectSingleContextValue>(useSelectSingle, props);
}
describe('when is not a single select DayPicker', () => {
test('the selected day should be undefined', () => {
const result = renderHook();
expect(result.current.selected).toBeUndefined();
});
});
describe('when a day is selected from DayPicker props', () => {
test('the selected day should be today', () => {
const dayPickerProps: DayPickerSingleProps = {
mode: 'single',
selected: today
};
const result = renderHook(dayPickerProps);
expect(result.current.selected).toBe(today);
});
});
describe('when onDayClick is called', () => {
const dayPickerProps: DayPickerSingleProps = {
mode: 'single',
onSelect: jest.fn(),
onDayClick: jest.fn()
};
const result = renderHook(dayPickerProps);
const activeModifiers = {};
const event = {} as MouseEvent;
test('should call the `onSelect` event handler', () => {
result.current.onDayClick?.(today, activeModifiers, event);
expect(dayPickerProps.onSelect).toHaveBeenCalledWith(
today,
today,
activeModifiers,
event
);
});
test('should call the `onDayClick` event handler', () => {
result.current.onDayClick?.(today, activeModifiers, event);
expect(dayPickerProps.onDayClick).toHaveBeenCalledWith(
today,
activeModifiers,
event
);
});
});
describe('if a selected day is not required', () => {
const dayPickerProps: DayPickerSingleProps = {
mode: 'single',
onSelect: jest.fn(),
required: false
};
test('should call the `onSelect` event handler with an undefined day', () => {
const result = renderHook(dayPickerProps);
const activeModifiers: ActiveModifiers = { selected: true };
const event = {} as MouseEvent;
result.current.onDayClick?.(today, activeModifiers, event);
expect(dayPickerProps.onSelect).toHaveBeenCalledWith(
undefined,
today,
activeModifiers,
event
);
});
});

View File

@ -0,0 +1,96 @@
import { createContext, ReactNode, useContext } from 'react';
import { DayPickerBase } from 'types/DayPickerBase';
import { DayPickerSingleProps, isDayPickerSingle } from 'types/DayPickerSingle';
import { DayClickEventHandler } from 'types/EventHandlers';
/** Represents the value of a {@link SelectSingleContext}. */
export interface SelectSingleContextValue {
/** The day that has been selected. */
selected: Date | undefined;
/** Event handler to attach to the day button to enable the single select. */
onDayClick?: DayClickEventHandler;
}
/**
* The SelectSingle context shares details about the selected days when in
* single selection mode.
*
* Access this context from the {@link useSelectSingle} hook.
*/
export const SelectSingleContext = createContext<
SelectSingleContextValue | undefined
>(undefined);
export interface SelectSingleProviderProps {
initialProps: DayPickerBase;
children?: ReactNode;
}
/** Provides the values for the {@link SelectSingleProvider}. */
export function SelectSingleProvider(
props: SelectSingleProviderProps
): JSX.Element {
if (!isDayPickerSingle(props.initialProps)) {
const emptyContextValue: SelectSingleContextValue = {
selected: undefined
};
return (
<SelectSingleContext.Provider value={emptyContextValue}>
{props.children}
</SelectSingleContext.Provider>
);
}
return (
<SelectSingleProviderInternal
initialProps={props.initialProps}
children={props.children}
/>
);
}
/** @private */
export interface SelectSingleProviderInternal {
initialProps: DayPickerSingleProps;
children?: ReactNode;
}
export function SelectSingleProviderInternal({
initialProps,
children
}: SelectSingleProviderInternal): JSX.Element {
const onDayClick: DayClickEventHandler = (day, activeModifiers, e) => {
initialProps.onDayClick?.(day, activeModifiers, e);
if (activeModifiers.selected && !initialProps.required) {
initialProps.onSelect?.(undefined, day, activeModifiers, e);
return;
}
initialProps.onSelect?.(day, day, activeModifiers, e);
};
const contextValue: SelectSingleContextValue = {
selected: initialProps.selected,
onDayClick
};
return (
<SelectSingleContext.Provider value={contextValue}>
{children}
</SelectSingleContext.Provider>
);
}
/**
* Hook to access the {@link SelectSingleContextValue}.
*
* This hook is meant to be used inside internal or custom components.
*/
export function useSelectSingle(): SelectSingleContextValue {
const context = useContext(SelectSingleContext);
if (!context) {
throw new Error(
'useSelectSingle must be used within a SelectSingleProvider'
);
}
return context;
}

View File

@ -0,0 +1 @@
export * from './SelectSingleContext';