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 @@
style.css.d.ts

View File

@ -0,0 +1,114 @@
import { DayPickerDefaultProps } from 'types/DayPickerDefault';
import { DayPickerMultipleProps } from 'types/DayPickerMultiple';
import { DayPickerRangeProps } from 'types/DayPickerRange';
import { DayPickerSingleProps } from 'types/DayPickerSingle';
import { Root } from './components/Root';
import { RootProvider } from './contexts/RootProvider';
export type DayPickerProps =
| DayPickerDefaultProps
| DayPickerSingleProps
| DayPickerMultipleProps
| DayPickerRangeProps;
/**
* DayPicker render a date picker component to let users pick dates from a
* calendar. See http://react-day-picker.js.org for updated documentation and
* examples.
*
* ### Customization
*
* DayPicker offers different customization props. For example,
*
* - show multiple months using `numberOfMonths`
* - display a dropdown to navigate the months via `captionLayout`
* - display the week numbers with `showWeekNumbers`
* - disable or hide days with `disabled` or `hidden`
*
* ### Controlling the months
*
* Change the initially displayed month using the `defaultMonth` prop. The
* displayed months are controlled by DayPicker and stored in its internal
* state. To control the months yourself, use `month` instead of `defaultMonth`
* and use the `onMonthChange` event to set it.
*
* To limit the months the user can navigate to, use
* `fromDate`/`fromMonth`/`fromYear` or `toDate`/`toMonth`/`toYear`.
*
* ### Selection modes
*
* DayPicker supports different selection mode that can be toggled using the
* `mode` prop:
*
* - `mode="single"`: only one day can be selected. Use `required` to make the
* selection required. Use the `onSelect` event handler to get the selected
* days.
* - `mode="multiple"`: users can select one or more days. Limit the amount of
* days that can be selected with the `min` or the `max` props.
* - `mode="range"`: users can select a range of days. Limit the amount of days
* in the range with the `min` or the `max` props.
* - `mode="default"` (default): the built-in selections are disabled. Implement
* your own selection mode with `onDayClick`.
*
* The selection modes should cover the most common use cases. In case you
* need a more refined way of selecting days, use `mode="default"`. Use the
* `selected` props and add the day event handlers to add/remove days from the
* selection.
*
* ### Modifiers
*
* A _modifier_ represents different styles or states for the days displayed in
* the calendar (like "selected" or "disabled"). Define custom modifiers using
* the `modifiers` prop.
*
* ### Formatters and custom component
*
* You can customize how the content is displayed in the date picker by using
* either the formatters or replacing the internal components.
*
* For the most common cases you want to use the `formatters` prop to change how
* the content is formatted in the calendar. Use the `components` prop to
* replace the internal components, like the navigation icons.
*
* ### Styling
*
* DayPicker comes with a default, basic style in `react-day-picker/style` use
* it as template for your own style.
*
* If you are using CSS modules, pass the imported styles object the
* `classNames` props.
*
* You can also style the elements via inline styles using the `styles` prop.
*
* ### Form fields
*
* If you need to bind the date picker to a form field, you can use the
* `useInput` hooks for a basic behavior. See the `useInput` source as an
* example to bind the date picker with form fields.
*
* ### Localization
*
* To localize DayPicker, import the locale from `date-fns` package and use the
* `locale` prop.
*
* For example, to use Spanish locale:
*
* ```
* import { es } from 'date-fns/locale';
* <DayPicker locale={es} />
* ```
*/
export function DayPicker(
props:
| DayPickerDefaultProps
| DayPickerSingleProps
| DayPickerMultipleProps
| DayPickerRangeProps
): JSX.Element {
return (
<RootProvider {...props}>
<Root initialProps={props} />
</RootProvider>
);
}

View File

@ -0,0 +1,45 @@
import { screen } from '@testing-library/react';
import { customRender } from 'test/render';
import { Button } from './Button';
let button: HTMLButtonElement;
describe('when rendered without props', () => {
beforeEach(() => {
customRender(<Button className="foo" style={{ color: 'blue' }} />);
button = screen.getByRole('button');
});
test('should render a button with type "button"', () => {
expect(button).toHaveAttribute('type', 'button');
});
test('should render a button with the button class name', () => {
expect(button).toHaveClass('rdp-button');
});
test('should render a button with the reset class name', () => {
expect(button).toHaveClass('rdp-button_reset');
});
test('should add the class name', () => {
expect(button).toHaveClass('foo');
});
test('should apply the style', () => {
expect(button).toHaveStyle({ color: 'blue' });
});
});
describe('when using class names and styles from context', () => {
beforeEach(() => {
customRender(<Button />, {
classNames: { button: 'foo' },
styles: { button: { color: 'red' } }
});
button = screen.getByRole('button');
});
test('should apply the style', () => {
expect(button).toHaveStyle({ color: 'red' });
});
test('should apply the class name', () => {
expect(button).toHaveClass('foo');
});
});

View File

@ -0,0 +1,34 @@
import { forwardRef } from 'react';
import { useDayPicker } from 'contexts/DayPicker';
/** The props for the {@link Button} component. */
export type ButtonProps = JSX.IntrinsicElements['button'];
/** Render a button HTML element applying the reset class name. */
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(props, ref) => {
const { classNames, styles } = useDayPicker();
const classNamesArr = [classNames.button_reset, classNames.button];
if (props.className) {
classNamesArr.push(props.className);
}
const className = classNamesArr.join(' ');
const style = { ...styles.button_reset, ...styles.button };
if (props.style) {
Object.assign(style, props.style);
}
return (
<button
{...props}
ref={ref}
type="button"
className={className}
style={style}
/>
);
}
);

View File

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

View File

@ -0,0 +1,108 @@
import { screen } from '@testing-library/react';
import { DayPickerProps } from 'DayPicker';
import { customRender } from 'test/render';
import {
getMonthCaption,
getMonthDropdown,
getNextButton,
getPrevButton,
getYearDropdown,
queryNextButton,
queryPrevButton
} from 'test/selectors';
import { freezeBeforeAll } from 'test/utils';
import { CustomComponents } from 'types/DayPickerBase';
import { Caption, CaptionProps } from './Caption';
const today = new Date(2021, 8);
freezeBeforeAll(today);
function setup(props: CaptionProps, dayPickerProps?: DayPickerProps) {
customRender(<Caption {...props} />, dayPickerProps);
}
describe('when navigation is disabled', () => {
const props = { displayMonth: today };
const dayPickerProps = { disableNavigation: true };
beforeEach(() => setup(props, dayPickerProps));
test('should display the caption label', () => {
expect(getMonthCaption()).toHaveTextContent('September 2021');
});
test('should not render the navigation', () => {
expect(queryPrevButton()).toBeNull();
expect(queryNextButton()).toBeNull();
});
});
describe('when using a custom CaptionLabel component', () => {
const components: CustomComponents = {
CaptionLabel: () => <>custom label foo</>
};
const props = { displayMonth: today };
beforeEach(() => {
setup(props, { components });
});
test('it should render the custom component instead', () => {
expect(screen.getByText('custom label foo')).toBeInTheDocument();
});
});
describe('when the caption layout is "dropdown"', () => {
const dayPickerProps: DayPickerProps = {
captionLayout: 'dropdown',
fromYear: 2020,
toYear: 2025
};
const props = { displayMonth: today };
beforeEach(() => {
setup(props, dayPickerProps);
});
test('should render the month drop-down', () => {
expect(getMonthDropdown()).toBeInTheDocument();
});
test('should render the year drop-down', () => {
expect(getYearDropdown()).toBeInTheDocument();
});
});
describe('when the caption layout is "buttons"', () => {
const dayPickerProps: DayPickerProps = {
captionLayout: 'buttons'
};
test('should render the next month button', () => {
customRender(<Caption displayMonth={today} />, dayPickerProps);
expect(getNextButton()).toBeInTheDocument();
});
test('should render the previous month button', () => {
customRender(<Caption displayMonth={today} />, dayPickerProps);
expect(getPrevButton()).toBeInTheDocument();
});
});
describe('when the caption layout is "dropdown-buttons"', () => {
const dayPickerProps: DayPickerProps = {
captionLayout: 'dropdown-buttons',
fromYear: 2020,
toYear: 2025
};
const props = { displayMonth: today };
beforeEach(() => {
setup(props, dayPickerProps);
});
test('should render the month drop-down', () => {
expect(getMonthDropdown()).toBeInTheDocument();
});
test('should render the year drop-down', () => {
expect(getYearDropdown()).toBeInTheDocument();
});
test('should render the next month button', () => {
expect(getNextButton()).toBeInTheDocument();
});
test('should render the previous month button', () => {
expect(getPrevButton()).toBeInTheDocument();
});
});

View File

@ -0,0 +1,77 @@
import { CaptionDropdowns } from 'components/CaptionDropdowns';
import { CaptionLabel } from 'components/CaptionLabel';
import { CaptionNavigation } from 'components/CaptionNavigation';
import { useDayPicker } from 'contexts/DayPicker';
/** Represent the props of the {@link Caption} component. */
export interface CaptionProps {
/** The ID for the heading element. Must be the same as the labelled-by in Table. */
id?: string;
/** The month where the caption is displayed. */
displayMonth: Date;
/** The index of the month where the caption is displayed. Older custom components may miss this prop. */
displayIndex?: number | undefined;
}
/**
* The layout of the caption:
*
* - `dropdown`: display dropdowns for choosing the month and the year.
* - `buttons`: display previous month / next month buttons.
* - `dropdown-buttons`: display both month / year dropdowns and previous month / next month buttons.
*/
export type CaptionLayout = 'dropdown' | 'buttons' | 'dropdown-buttons';
/**
* Render the caption of a month. The caption has a different layout when
* setting the {@link DayPickerBase.captionLayout} prop.
*/
export function Caption(props: CaptionProps): JSX.Element {
const { classNames, disableNavigation, styles, captionLayout, components } =
useDayPicker();
const CaptionLabelComponent = components?.CaptionLabel ?? CaptionLabel;
let caption: JSX.Element;
if (disableNavigation) {
caption = (
<CaptionLabelComponent id={props.id} displayMonth={props.displayMonth} />
);
} else if (captionLayout === 'dropdown') {
caption = (
<CaptionDropdowns displayMonth={props.displayMonth} id={props.id} />
);
} else if (captionLayout === 'dropdown-buttons') {
caption = (
<>
<CaptionDropdowns
displayMonth={props.displayMonth}
displayIndex={props.displayIndex}
id={props.id}
/>
<CaptionNavigation
displayMonth={props.displayMonth}
displayIndex={props.displayIndex}
id={props.id}
/>
</>
);
} else {
caption = (
<>
<CaptionLabelComponent
id={props.id}
displayMonth={props.displayMonth}
displayIndex={props.displayIndex}
/>
<CaptionNavigation displayMonth={props.displayMonth} id={props.id} />
</>
);
}
return (
<div className={classNames.caption} style={styles.caption}>
{caption}
</div>
);
}

View File

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

View File

@ -0,0 +1,121 @@
import { screen } from '@testing-library/react';
import { setMonth, setYear } from 'date-fns';
import { DayPickerProps } from 'DayPicker';
import { customRender } from 'test/render';
import {
getMonthDropdown,
getYearDropdown,
queryMonthDropdown,
queryYearDropdown
} from 'test/selectors';
import { user } from 'test/user';
import { freezeBeforeAll } from 'test/utils';
import { CaptionProps } from 'components/Caption';
import { CustomComponents } from 'types/DayPickerBase';
import { CaptionDropdowns } from './CaptionDropdowns';
const today = new Date(2021, 8);
const fromYear = 2020;
const toYear = 2025;
freezeBeforeAll(today);
function setup(props: CaptionProps, dayPickerProps?: DayPickerProps) {
customRender(<CaptionDropdowns {...props} />, dayPickerProps);
}
describe('when using a custom CaptionLabel component', () => {
const components: CustomComponents = {
CaptionLabel: () => <>custom label foo</>
};
const props = { displayMonth: today };
beforeEach(() => {
setup(props, { components });
});
test('it should render the custom component instead', () => {
expect(screen.getByText('custom label foo')).toBeInTheDocument();
});
});
describe('when rendered with custom styles or classnames', () => {
let container: HTMLElement;
beforeEach(() => {
const dayPickerProps: DayPickerProps = {
captionLayout: 'dropdown',
fromYear,
toYear,
classNames: { caption_dropdowns: 'foo_dropdowns' },
styles: { caption_dropdowns: { color: 'red' } }
};
const view = customRender(
<CaptionDropdowns displayMonth={today} />,
dayPickerProps
);
container = view.container;
});
test('should use the `caption_dropdowns` class name', () => {
expect(container.firstChild).toHaveClass('foo_dropdowns');
});
test('should use the `caption_dropdowns` style', () => {
expect(container.firstChild).toHaveStyle({ color: 'red' });
});
test('should render the month drop-down', () => {
expect(getMonthDropdown()).toBeInTheDocument();
});
test('should render the year drop-down', () => {
expect(getYearDropdown()).toBeInTheDocument();
});
});
describe('when a month is selected', () => {
const dayPickerProps: DayPickerProps = {
captionLayout: 'dropdown',
fromYear,
toYear,
onMonthChange: jest.fn()
};
beforeEach(() => {
customRender(<CaptionDropdowns displayMonth={today} />, dayPickerProps);
});
describe('from the months drop-down', () => {
const newMonth = setMonth(today, 0);
beforeEach(async () => {
await user.selectOptions(
getMonthDropdown(),
newMonth.getMonth().toString()
);
});
test('should call the `onMonthChange` callback', () => {
expect(dayPickerProps.onMonthChange).toHaveBeenCalledWith(newMonth);
});
});
describe('from the years drop-down', () => {
const newYear = setYear(today, 2022);
beforeEach(async () => {
await user.selectOptions(
getYearDropdown(),
newYear.getFullYear().toString()
);
});
test('should call the `onMonthChange` callback', () => {
expect(dayPickerProps.onMonthChange).toHaveBeenCalledWith(newYear);
});
});
});
describe('when no date limits are set', () => {
const dayPickerProps: DayPickerProps = {
captionLayout: 'dropdown'
};
beforeEach(() => {
customRender(<CaptionDropdowns displayMonth={today} />, dayPickerProps);
});
test('should not render the drop-downs', () => {
expect(queryMonthDropdown()).toBeNull();
expect(queryYearDropdown()).toBeNull();
});
});

View File

@ -0,0 +1,44 @@
import { addMonths } from 'date-fns';
import { CaptionProps } from 'components/Caption/Caption';
import { CaptionLabel } from 'components/CaptionLabel';
import { MonthsDropdown } from 'components/MonthsDropdown';
import { YearsDropdown } from 'components/YearsDropdown';
import { useDayPicker } from 'contexts/DayPicker';
import { useNavigation } from 'contexts/Navigation';
import { MonthChangeEventHandler } from 'types/EventHandlers';
/**
* Render a caption with the dropdowns to navigate between months and years.
*/
export function CaptionDropdowns(props: CaptionProps): JSX.Element {
const { classNames, styles, components } = useDayPicker();
const { goToMonth } = useNavigation();
const handleMonthChange: MonthChangeEventHandler = (newMonth) => {
goToMonth(
addMonths(newMonth, props.displayIndex ? -props.displayIndex : 0)
);
};
const CaptionLabelComponent = components?.CaptionLabel ?? CaptionLabel;
const captionLabel = (
<CaptionLabelComponent id={props.id} displayMonth={props.displayMonth} />
);
return (
<div
className={classNames.caption_dropdowns}
style={styles.caption_dropdowns}
>
{/* Caption label is visually hidden but for a11y. */}
<div className={classNames.vhidden}>{captionLabel}</div>
<MonthsDropdown
onChange={handleMonthChange}
displayMonth={props.displayMonth}
/>
<YearsDropdown
onChange={handleMonthChange}
displayMonth={props.displayMonth}
/>
</div>
);
}

View File

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

View File

@ -0,0 +1,27 @@
import { customRender } from 'test/render';
import { getMonthCaption } from 'test/selectors';
import { freezeBeforeAll } from 'test/utils';
import { CaptionLabel } from './CaptionLabel';
const today = new Date(1979, 8);
freezeBeforeAll(today);
test('should render the formatted display month', () => {
customRender(<CaptionLabel displayMonth={today} />);
expect(getMonthCaption()).toHaveTextContent('September 1979');
});
test('should apply the `caption_label` class name', () => {
customRender(<CaptionLabel displayMonth={today} />, {
classNames: { caption_label: 'foo' }
});
expect(getMonthCaption()).toHaveClass('foo');
});
test('should apply the `caption_label` style', () => {
customRender(<CaptionLabel displayMonth={today} />, {
styles: { caption_label: { color: 'red' } }
});
expect(getMonthCaption()).toHaveStyle({ color: 'red' });
});

View File

@ -0,0 +1,32 @@
import { useDayPicker } from 'contexts/DayPicker';
/** The props for the {@link CaptionLabel} component. */
export interface CaptionLabelProps {
/** The ID for the heading element. Must be the same as the labelled-by in Table. */
id?: string;
/** The month where the caption is displayed. */
displayMonth: Date;
/** The index of the month where the caption is displayed. Older custom components may miss this prop. */
displayIndex?: number | undefined;
}
/** Render the caption for the displayed month. This component is used when `captionLayout="buttons"`. */
export function CaptionLabel(props: CaptionLabelProps): JSX.Element {
const {
locale,
classNames,
styles,
formatters: { formatCaption }
} = useDayPicker();
return (
<div
className={classNames.caption_label}
style={styles.caption_label}
aria-live="polite"
role="presentation"
id={props.id}
>
{formatCaption(props.displayMonth, { locale })}
</div>
);
}

View File

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

View File

@ -0,0 +1,144 @@
import { addMonths } from 'date-fns';
import { DayPickerProps } from 'DayPicker';
import { customRender } from 'test/render';
import {
getNextButton,
getPrevButton,
queryNextButton,
queryPrevButton
} from 'test/selectors';
import { user } from 'test/user';
import { freezeBeforeAll } from 'test/utils';
import { CaptionNavigation } from './CaptionNavigation';
const today = new Date(2021, 8);
freezeBeforeAll(today);
describe('when rendered', () => {
const dayPickerProps: DayPickerProps = {
captionLayout: 'buttons'
};
test('should render the next month button', () => {
customRender(<CaptionNavigation displayMonth={today} />, dayPickerProps);
expect(getNextButton()).toBeInTheDocument();
});
test('should render the previous month button', () => {
customRender(<CaptionNavigation displayMonth={today} />, dayPickerProps);
expect(getPrevButton()).toBeInTheDocument();
});
describe('when displaying the first of multiple months', () => {
const numberOfMonths = 3;
beforeEach(() => {
customRender(<CaptionNavigation displayMonth={today} />, {
...dayPickerProps,
numberOfMonths
});
});
test('should not display the next month button', () => {
expect(queryNextButton()).toBeNull();
});
test('should show the previous month button', () => {
expect(getPrevButton()).toBeInTheDocument();
});
});
describe('when displaying the last of multiple months', () => {
const numberOfMonths = 3;
beforeEach(() => {
const lastMonth = addMonths(today, numberOfMonths - 1);
customRender(<CaptionNavigation displayMonth={lastMonth} />, {
...dayPickerProps,
numberOfMonths
});
});
test('should hide the previous month button', () => {
expect(queryPrevButton()).toBeNull();
});
test('should show the next month button', () => {
expect(getNextButton()).toBeInTheDocument();
});
});
describe('when displaying a month in the middle of multiple months', () => {
const numberOfMonths = 3;
beforeEach(() => {
const lastMonth = addMonths(today, numberOfMonths - 2);
customRender(<CaptionNavigation displayMonth={lastMonth} />, {
...dayPickerProps,
numberOfMonths
});
});
test('should not render the previous month button', () => {
expect(queryPrevButton()).toBeNull();
});
test('should not render the next month button', () => {
expect(queryNextButton()).toBeNull();
});
});
describe('when clicking the previous button', () => {
describe('and a previous month is defined', () => {
const testContext = {
...dayPickerProps,
onMonthChange: jest.fn()
};
const previousMonth = addMonths(today, -1);
beforeEach(async () => {
customRender(<CaptionNavigation displayMonth={today} />, testContext);
await user.click(getPrevButton());
});
test('should call the `onMonthChange` callback', () => {
expect(testContext.onMonthChange).toHaveBeenCalledWith(previousMonth);
});
});
describe('and the previous month is not defined', () => {
const testContext = {
...dayPickerProps,
fromDate: today,
onMonthChange: jest.fn()
};
beforeEach(async () => {
customRender(<CaptionNavigation displayMonth={today} />, testContext);
await user.click(getPrevButton());
});
test('should call the `onMonthChange` callback', () => {
expect(testContext.onMonthChange).not.toHaveBeenCalled();
});
});
});
describe('when clicking the next month button', () => {
describe('and the next month is defined', () => {
const testContext = {
...dayPickerProps,
onMonthChange: jest.fn()
};
const nextMonth = addMonths(today, 1);
beforeEach(async () => {
customRender(<CaptionNavigation displayMonth={today} />, testContext);
await user.click(getNextButton());
});
test('should call the `onMonthChange` callback', () => {
expect(testContext.onMonthChange).toHaveBeenCalledWith(nextMonth);
});
});
describe('and the next month is not defined', () => {
const testContext = {
...dayPickerProps,
toDate: today,
onMonthChange: jest.fn()
};
beforeEach(async () => {
customRender(<CaptionNavigation displayMonth={today} />, testContext);
await user.click(getNextButton());
});
test('should call the `onMonthChange` callback', () => {
expect(testContext.onMonthChange).not.toHaveBeenCalled();
});
});
});
});

View File

@ -0,0 +1,49 @@
import { MouseEventHandler } from 'react';
import { isSameMonth } from 'date-fns';
import { CaptionProps } from 'components/Caption/Caption';
import { Navigation } from 'components/Navigation';
import { useDayPicker } from 'contexts/DayPicker';
import { useNavigation } from 'contexts/Navigation';
/**
* Render a caption with a button-based navigation.
*/
export function CaptionNavigation(props: CaptionProps): JSX.Element {
const { numberOfMonths } = useDayPicker();
const { previousMonth, nextMonth, goToMonth, displayMonths } =
useNavigation();
const displayIndex = displayMonths.findIndex((month) =>
isSameMonth(props.displayMonth, month)
);
const isFirst = displayIndex === 0;
const isLast = displayIndex === displayMonths.length - 1;
const hideNext = numberOfMonths > 1 && (isFirst || !isLast);
const hidePrevious = numberOfMonths > 1 && (isLast || !isFirst);
const handlePreviousClick: MouseEventHandler = () => {
if (!previousMonth) return;
goToMonth(previousMonth);
};
const handleNextClick: MouseEventHandler = () => {
if (!nextMonth) return;
goToMonth(nextMonth);
};
return (
<Navigation
displayMonth={props.displayMonth}
hideNext={hideNext}
hidePrevious={hidePrevious}
nextMonth={nextMonth}
previousMonth={previousMonth}
onPreviousClick={handlePreviousClick}
onNextClick={handleNextClick}
/>
);
}

View File

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

View File

@ -0,0 +1,82 @@
import { screen } from '@testing-library/react';
import { DayPickerProps } from 'DayPicker';
import { customRender } from 'test/render';
import { freezeBeforeAll } from 'test/utils';
import { CustomComponents } from 'types/DayPickerBase';
import { Day, DayProps } from './Day';
const today = new Date(2021, 8);
freezeBeforeAll(today);
const date = today;
const displayMonth = today;
const props: DayProps = {
date: date,
displayMonth
};
describe('when the day to render has an hidden modifier', () => {
const dayPickerProps: DayPickerProps = {
modifiers: { hidden: date }
};
beforeEach(() => {
customRender(<Day {...props} />, dayPickerProps);
});
test('should render an empty grid cell', () => {
const cell = screen.getByRole('gridcell');
expect(cell).toBeEmptyDOMElement();
});
});
describe('when a no selection mode and no "onDayClick"', () => {
const dayPickerProps: DayPickerProps = { mode: 'default' };
beforeEach(() => {
customRender(<Day {...props} />, dayPickerProps);
});
test('should render a div', () => {
const cell = screen.getByRole('gridcell');
expect(cell.nodeName).toBe('DIV');
});
});
describe('when a selection mode is set', () => {
const dayPickerProps: DayPickerProps = {
mode: 'single'
};
beforeEach(() => {
customRender(<Day {...props} />, dayPickerProps);
});
test('should render a button named "day"', () => {
const cell = screen.getByRole('gridcell');
expect(cell.nodeName).toBe('BUTTON');
expect(cell).toHaveAttribute('name', 'day');
});
});
describe('when "onDayClick" is present', () => {
const dayPickerProps: DayPickerProps = {
onDayClick: jest.fn()
};
beforeEach(() => {
customRender(<Day {...props} />, dayPickerProps);
});
test('should render a button', () => {
const cell = screen.getByRole('gridcell');
expect(cell.nodeName).toBe('BUTTON');
});
});
describe('when using a custom DayContent component', () => {
const components: CustomComponents = {
DayContent: () => <>Custom DayContent</>
};
beforeEach(() => {
customRender(<Day {...props} />, { components });
});
test('it should render the custom component instead', () => {
expect(screen.getByText('Custom DayContent')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,30 @@
import { useRef } from 'react';
import { useDayRender } from 'hooks/useDayRender';
import { Button } from '../Button';
/** Represent the props used by the {@link Day} component. */
export interface DayProps {
/** The month where the date is displayed. */
displayMonth: Date;
/** The date to render. */
date: Date;
}
/**
* The content of a day cell as a button or span element according to its
* modifiers.
*/
export function Day(props: DayProps): JSX.Element {
const buttonRef = useRef<HTMLButtonElement>(null);
const dayRender = useDayRender(props.date, props.displayMonth, buttonRef);
if (dayRender.isHidden) {
return <div role="gridcell"></div>;
}
if (!dayRender.isButton) {
return <div {...dayRender.divProps} />;
}
return <Button name="day" ref={buttonRef} {...dayRender.buttonProps} />;
}

View File

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

View File

@ -0,0 +1,37 @@
import { es } from 'date-fns/locale';
import { DayPickerProps } from 'DayPicker';
import { customRender } from 'test/render';
import { freezeBeforeAll } from 'test/utils';
import { DayContent, DayContentProps } from 'components/DayContent';
const today = new Date(2021, 8);
freezeBeforeAll(today);
let container: HTMLElement;
function setup(props: DayContentProps, dayPickerProps?: DayPickerProps) {
const view = customRender(<DayContent {...props} />, dayPickerProps);
container = view.container;
}
const date = today;
const displayMonth = today;
const props: DayContentProps = {
date: date,
displayMonth,
activeModifiers: {}
};
const dayPickerProps: DayPickerProps = {
locale: es
};
describe('when rendered', () => {
beforeEach(() => {
setup(props, dayPickerProps);
});
test('contains the formatted day', () => {
expect(container.firstChild).toHaveTextContent('1');
});
});

View File

@ -0,0 +1,22 @@
import { useDayPicker } from 'contexts/DayPicker';
import { ActiveModifiers } from 'types/Modifiers';
/** Represent the props for the {@link DayContent} component. */
export interface DayContentProps {
/** The date representing the day. */
date: Date;
/** The month where the day is displayed. */
displayMonth: Date;
/** The active modifiers for the given date. */
activeModifiers: ActiveModifiers;
}
/** Render the content of the day cell. */
export function DayContent(props: DayContentProps): JSX.Element {
const {
locale,
formatters: { formatDay }
} = useDayPicker();
return <>{formatDay(props.date, { locale })}</>;
}

View File

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

View File

@ -0,0 +1,72 @@
import { fireEvent, screen } from '@testing-library/react';
import { DayPickerProps } from 'DayPicker';
import { customRender } from 'test/render';
import { freezeBeforeAll } from 'test/utils';
import { Dropdown, DropdownProps } from 'components/Dropdown';
import { defaultClassNames } from 'contexts/DayPicker/defaultClassNames';
import { CustomComponents } from 'types/DayPickerBase';
const today = new Date(2021, 8);
freezeBeforeAll(today);
function setup(props: DropdownProps, dayPickerProps?: DayPickerProps) {
customRender(<Dropdown {...props} />, dayPickerProps);
}
const props: Required<DropdownProps> = {
name: 'dropdown',
'aria-label': 'foo',
onChange: jest.fn(),
caption: 'Some caption',
className: 'test',
value: 'bar',
children: <option value={'bar'} />,
style: {}
};
describe('when rendered', () => {
let combobox: HTMLElement;
let label: HTMLElement;
beforeEach(() => {
setup(props);
combobox = screen.getByRole('combobox');
label = screen.getByText(props['aria-label']);
});
test('should render the vhidden aria label', () => {
expect(label).toHaveClass(defaultClassNames.vhidden);
});
test('should render the combobox', () => {
expect(combobox).toBeInTheDocument();
});
describe('when the combobox changes', () => {
beforeEach(() => {
fireEvent.change(combobox);
});
test('should call the "onChange" eve, nt handler', () => {
expect(props.onChange).toHaveBeenCalled();
});
});
test('should render the combobox with the given value', () => {
expect(combobox).toHaveValue(props.value);
});
});
describe('when using a custom IconDropdown component', () => {
const components: CustomComponents = {
IconDropdown: () => <div>Custom IconDropdown</div>
};
beforeEach(() => {
setup(props, { components });
});
test('it should render the custom component instead', () => {
expect(screen.getByText('Custom IconDropdown')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,66 @@
import {
ChangeEventHandler,
CSSProperties,
ReactNode,
SelectHTMLAttributes
} from 'react';
import { IconDropdown } from 'components/IconDropdown';
import { useDayPicker } from 'contexts/DayPicker';
/** The props for the {@link Dropdown} component. */
export interface DropdownProps {
/** The name attribute of the element. */
name?: string;
/** The caption displayed to replace the hidden select. */
caption?: ReactNode;
children?: SelectHTMLAttributes<HTMLSelectElement>['children'];
className?: string;
['aria-label']?: string;
style?: CSSProperties;
/** The selected value. */
value?: string | number;
onChange?: ChangeEventHandler<HTMLSelectElement>;
}
/**
* Render a styled select component displaying a caption and a custom
* drop-down icon.
*/
export function Dropdown(props: DropdownProps): JSX.Element {
const { onChange, value, children, caption, className, style } = props;
const dayPicker = useDayPicker();
const IconDropdownComponent =
dayPicker.components?.IconDropdown ?? IconDropdown;
return (
<div className={className} style={style}>
<span className={dayPicker.classNames.vhidden}>
{props['aria-label']}
</span>
<select
name={props.name}
aria-label={props['aria-label']}
className={dayPicker.classNames.dropdown}
style={dayPicker.styles.dropdown}
value={value}
onChange={onChange}
>
{children}
</select>
<div
className={dayPicker.classNames.caption_label}
style={dayPicker.styles.caption_label}
aria-hidden="true"
>
{caption}
{
<IconDropdownComponent
className={dayPicker.classNames.dropdown_icon}
style={dayPicker.styles.dropdown_icon}
/>
}
</div>
</div>
);
}

View File

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

View File

@ -0,0 +1,27 @@
import { customRender } from 'test/render';
import { getTableFooter, queryTableFooter } from 'test/selectors';
import { Footer } from './Footer';
customRender(
<table role="grid">
<Footer />
</table>
);
test('should not render anything as default', () => {
expect(queryTableFooter()).toBeNull();
});
describe('when using the `footer` prop', () => {
beforeEach(() => {
customRender(
<table role="grid">
<Footer />
</table>,
{ footer: 'footer_foo' }
);
});
test('should render the table footer', () => {
expect(getTableFooter()).toHaveTextContent('footer_foo');
});
});

View File

@ -0,0 +1,23 @@
import { useDayPicker } from 'contexts/DayPicker';
export interface FooterProps {
/** The month where the footer is displayed. */
displayMonth?: Date;
}
/** Render the Footer component (empty as default).*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export function Footer(props: FooterProps): JSX.Element {
const {
footer,
styles,
classNames: { tfoot }
} = useDayPicker();
if (!footer) return <></>;
return (
<tfoot className={tfoot} style={styles.tfoot}>
<tr>
<td colSpan={8}>{footer}</td>
</tr>
</tfoot>
);
}

View File

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

View File

@ -0,0 +1,65 @@
import { RenderResult, screen } from '@testing-library/react';
import { DayPickerProps } from 'DayPicker';
import { customRender } from 'test/render';
import { Head } from './Head';
let container: HTMLElement;
let view: RenderResult;
function setup(dayPickerProps: DayPickerProps = {}) {
view = customRender(
<table>
<Head />
</table>,
dayPickerProps
);
container = view.container.firstChild as HTMLTableCellElement;
}
const dayPickerProps = {
styles: {
head: { color: 'red' },
head_row: { color: 'blue' },
head_cell: { color: 'green' }
},
classNames: {
head: 'foo',
head_row: 'foo_row',
head_cell: 'foo_head-cell'
}
};
describe('when rendered', () => {
beforeEach(() => {
setup(dayPickerProps);
});
test('thead should have the `head` style', () => {
expect(container.firstChild).toHaveStyle(dayPickerProps.styles.head);
});
test('thead should have the `head` class', () => {
expect(container.firstChild).toHaveClass(dayPickerProps.classNames.head);
});
});
describe('when using a custom HeadRow component', () => {
beforeEach(() => {
setup({
...dayPickerProps,
components: {
HeadRow: () => (
<tr>
<td>custom head</td>
</tr>
)
}
});
});
test('should render the custom component', () => {
expect(screen.getByText('custom head')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,13 @@
import { HeadRow } from 'components/HeadRow';
import { useDayPicker } from 'contexts/DayPicker';
/** Render the table head. */
export function Head(): JSX.Element {
const { classNames, styles, components } = useDayPicker();
const HeadRowComponent = components?.HeadRow ?? HeadRow;
return (
<thead style={styles.head} className={classNames.head}>
<HeadRowComponent />
</thead>
);
}

View File

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

View File

@ -0,0 +1,85 @@
import { RenderResult } from '@testing-library/react';
import { DayPickerProps } from 'DayPicker';
import { customRender } from 'test/render';
import { HeadRow } from './HeadRow';
let container: HTMLElement;
let view: RenderResult;
let thElements: HTMLTableCellElement[];
function setup(dayPickerProps: DayPickerProps = {}) {
view = customRender(
<table>
<thead>
<HeadRow />
</thead>
</table>,
dayPickerProps
);
container = view.container.firstChild?.firstChild as HTMLTableRowElement;
thElements = Array.from(container.getElementsByTagName('th'));
}
const dayPickerProps = {
styles: {
head: { color: 'red' },
head_row: { color: 'blue' },
head_cell: { color: 'green' }
},
classNames: {
head: 'foo',
head_row: 'foo_row',
head_cell: 'foo_head-cell'
}
};
describe('when rendered', () => {
beforeEach(() => {
setup(dayPickerProps);
});
test('tr element should have the `head_row` style', () => {
expect(container.firstChild).toHaveStyle(dayPickerProps.styles.head_row);
});
test('tr element should have the `head_row` class', () => {
expect(container.firstChild).toHaveClass(
dayPickerProps.classNames.head_row
);
});
test('should render 7 head elements', () => {
expect(thElements).toHaveLength(7);
});
test('should render the head elements with the "head_cell" class name', () => {
thElements.forEach((el) => {
expect(el).toHaveClass(dayPickerProps.classNames.head_cell);
});
});
});
describe('when showing the week numbers', () => {
beforeEach(() => {
setup({ ...dayPickerProps, showWeekNumber: true });
});
test('should render 8 head elements', () => {
expect(thElements).toHaveLength(7);
});
test('should render the head elements with the "head_cell" class name', () => {
thElements.forEach((el) => {
expect(el).toHaveClass(dayPickerProps.classNames.head_cell);
});
});
test('should render the head elements with the "head_cell" style', () => {
thElements.forEach((el) => {
expect(el).toHaveStyle(dayPickerProps.styles.head_cell);
});
});
test('should render the head elements with the "col" scope', () => {
thElements.forEach((el) => {
expect(el).toHaveAttribute('scope', 'col');
});
});
});

View File

@ -0,0 +1,40 @@
import { useDayPicker } from 'contexts/DayPicker';
import { getWeekdays } from './utils';
/**
* Render the HeadRow component - i.e. the table head row with the weekday names.
*/
export function HeadRow(): JSX.Element {
const {
classNames,
styles,
showWeekNumber,
locale,
weekStartsOn,
ISOWeek,
formatters: { formatWeekdayName },
labels: { labelWeekday }
} = useDayPicker();
const weekdays = getWeekdays(locale, weekStartsOn, ISOWeek);
return (
<tr style={styles.head_row} className={classNames.head_row}>
{showWeekNumber && (
<td style={styles.head_cell} className={classNames.head_cell}></td>
)}
{weekdays.map((weekday, i) => (
<th
key={i}
scope="col"
className={classNames.head_cell}
style={styles.head_cell}
aria-label={labelWeekday(weekday, { locale })}
>
{formatWeekdayName(weekday, { locale })}
</th>
))}
</tr>
);
}

View File

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

View File

@ -0,0 +1,46 @@
import { es } from 'date-fns/locale';
import { freezeBeforeAll } from 'test/utils';
import { getWeekdays } from './getWeekdays';
const today = new Date(2022, 1, 12);
const prevSunday = new Date(2022, 1, 6);
const prevMonday = new Date(2022, 1, 7);
freezeBeforeAll(today);
let result: Date[];
describe('when rendered without a locale', () => {
beforeEach(() => {
result = getWeekdays();
});
test('should return 7 days', () => {
expect(result).toHaveLength(7);
});
test('should return Sunday as first day', () => {
expect(result[0]).toEqual(prevSunday);
});
});
describe.each<0 | 1 | 2 | 3 | 4 | 5 | 6>([0, 1, 2, 3, 4, 5, 6])(
'when week start on %s',
(weekStartsOn) => {
beforeEach(() => {
result = getWeekdays(es, weekStartsOn);
});
test('the first date should be weekStartsOn', () => {
expect(result[0].getDay()).toBe(weekStartsOn);
});
}
);
describe('when using ISO week', () => {
beforeEach(() => {
result = getWeekdays(es, 3, true);
});
test('should return Monday as first day', () => {
expect(result[0]).toEqual(prevMonday);
});
});

View File

@ -0,0 +1,24 @@
import { addDays, Locale, startOfISOWeek, startOfWeek } from 'date-fns';
/**
* Generate a series of 7 days, starting from the week, to use for formatting
* the weekday names (Monday, Tuesday, etc.).
*/
export function getWeekdays(
locale?: Locale,
/** The index of the first day of the week (0 - Sunday). */
weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6,
/** Use ISOWeek instead of locale/ */
ISOWeek?: boolean
): Date[] {
const start = ISOWeek
? startOfISOWeek(new Date())
: startOfWeek(new Date(), { locale, weekStartsOn });
const days = [];
for (let i = 0; i < 7; i++) {
const day = addDays(start, i);
days.push(day);
}
return days;
}

View File

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

View File

@ -0,0 +1,18 @@
import { customRender } from 'test/render';
import { IconDropdown } from './IconDropdown';
let root: HTMLElement;
beforeEach(() => {
const view = customRender(
<IconDropdown className="foo" style={{ color: 'red' }} />
);
root = view.container.firstChild as HTMLElement;
});
test('should add the class name', () => {
expect(root).toHaveClass('foo');
});
test('should apply the style', () => {
expect(root).toHaveStyle({ color: 'red' });
});

View File

@ -0,0 +1,22 @@
import { StyledComponent } from 'types/Styles';
/**
* Render the icon in the styled drop-down.
*/
export function IconDropdown(props: StyledComponent): JSX.Element {
return (
<svg
width="8px"
height="8px"
viewBox="0 0 120 120"
data-testid="iconDropdown"
{...props}
>
<path
d="M4.22182541,48.2218254 C8.44222828,44.0014225 15.2388494,43.9273804 19.5496459,47.9996989 L19.7781746,48.2218254 L60,88.443 L100.221825,48.2218254 C104.442228,44.0014225 111.238849,43.9273804 115.549646,47.9996989 L115.778175,48.2218254 C119.998577,52.4422283 120.07262,59.2388494 116.000301,63.5496459 L115.778175,63.7781746 L67.7781746,111.778175 C63.5577717,115.998577 56.7611506,116.07262 52.4503541,112.000301 L52.2218254,111.778175 L4.22182541,63.7781746 C-0.0739418023,59.4824074 -0.0739418023,52.5175926 4.22182541,48.2218254 Z"
fill="currentColor"
fillRule="nonzero"
></path>
</svg>
);
}

View File

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

View File

@ -0,0 +1,18 @@
import { customRender } from 'test/render';
import { IconLeft } from './IconLeft';
let root: HTMLElement;
beforeEach(() => {
const view = customRender(
<IconLeft className="foo" style={{ color: 'red' }} />
);
root = view.container.firstChild as HTMLElement;
});
test('should add the class name', () => {
expect(root).toHaveClass('foo');
});
test('should apply the style', () => {
expect(root).toHaveStyle({ color: 'red' });
});

View File

@ -0,0 +1,16 @@
import { StyledComponent } from 'types/Styles';
/**
* Render the "previous month" button in the navigation.
*/
export function IconLeft(props: StyledComponent): JSX.Element {
return (
<svg width="16px" height="16px" viewBox="0 0 120 120" {...props}>
<path
d="M69.490332,3.34314575 C72.6145263,0.218951416 77.6798462,0.218951416 80.8040405,3.34314575 C83.8617626,6.40086786 83.9268205,11.3179931 80.9992143,14.4548388 L80.8040405,14.6568542 L35.461,60 L80.8040405,105.343146 C83.8617626,108.400868 83.9268205,113.317993 80.9992143,116.454839 L80.8040405,116.656854 C77.7463184,119.714576 72.8291931,119.779634 69.6923475,116.852028 L69.490332,116.656854 L18.490332,65.6568542 C15.4326099,62.5991321 15.367552,57.6820069 18.2951583,54.5451612 L18.490332,54.3431458 L69.490332,3.34314575 Z"
fill="currentColor"
fillRule="nonzero"
></path>
</svg>
);
}

View File

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

View File

@ -0,0 +1,18 @@
import { customRender } from 'test/render';
import { IconRight } from './IconRight';
let root: HTMLElement;
beforeEach(() => {
const view = customRender(
<IconRight className="foo" style={{ color: 'red' }} />
);
root = view.container.firstChild as HTMLElement;
});
test('should add the class name', () => {
expect(root).toHaveClass('foo');
});
test('should apply the style', () => {
expect(root).toHaveStyle({ color: 'red' });
});

View File

@ -0,0 +1,15 @@
import { StyledComponent } from 'types/Styles';
/**
* Render the "next month" button in the navigation.
*/
export function IconRight(props: StyledComponent): JSX.Element {
return (
<svg width="16px" height="16px" viewBox="0 0 120 120" {...props}>
<path
d="M49.8040405,3.34314575 C46.6798462,0.218951416 41.6145263,0.218951416 38.490332,3.34314575 C35.4326099,6.40086786 35.367552,11.3179931 38.2951583,14.4548388 L38.490332,14.6568542 L83.8333725,60 L38.490332,105.343146 C35.4326099,108.400868 35.367552,113.317993 38.2951583,116.454839 L38.490332,116.656854 C41.5480541,119.714576 46.4651794,119.779634 49.602025,116.852028 L49.8040405,116.656854 L100.804041,65.6568542 C103.861763,62.5991321 103.926821,57.6820069 100.999214,54.5451612 L100.804041,54.3431458 L49.8040405,3.34314575 Z"
fill="currentColor"
></path>
</svg>
);
}

View File

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

View File

@ -0,0 +1,230 @@
import { screen } from '@testing-library/react';
import { DayPickerProps } from 'DayPicker';
import { customRender } from 'test/render';
import { getMonthCaption, getMonthGrid } from 'test/selectors';
import { CustomComponents } from 'types/DayPickerBase';
import { Month, MonthProps } from './Month';
let root: HTMLDivElement;
const displayMonth = new Date(2022, 10, 4);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const testStyles: Record<string, any> = {
caption_start: { color: 'red' },
caption_end: { background: 'blue' },
caption_between: { fontSize: 20 }
};
const testClassNames: Record<string, string> = {
caption_start: 'caption_start',
caption_end: 'caption_end',
caption_between: 'caption_between'
};
type Test = {
monthProps: MonthProps;
dayPickerProps: DayPickerProps;
expected: string[];
notExpected: string[];
};
function setup(props: MonthProps, dayPickerProps?: DayPickerProps) {
const view = customRender(<Month {...props} />, dayPickerProps);
root = view.container.firstChild as HTMLDivElement;
}
describe('when rendered', () => {
beforeEach(() => {
setup({ displayIndex: 0, displayMonth });
});
test('the caption id should be the aria-labelledby of the grid', () => {
const captionId = getMonthCaption().getAttribute('id');
const gridLabelledBy = getMonthGrid().getAttribute('aria-labelledby');
expect(captionId).toEqual(gridLabelledBy);
});
});
describe('when rendered with a custom id', () => {
const id = 'custom-id';
beforeEach(() => {
setup({ displayIndex: 0, displayMonth }, { id });
});
test('the caption id should include the display index', () => {
const captionId = getMonthCaption().getAttribute('id');
expect(captionId).toEqual('custom-id-0');
});
test('the table id should include the display index', () => {
const tableId = getMonthGrid().getAttribute('id');
expect(tableId).toEqual('custom-id-grid-0');
});
});
describe('when using a custom Caption component', () => {
const components: CustomComponents = {
Caption: () => <>custom caption foo</>
};
beforeEach(() => {
setup({ displayIndex: 0, displayMonth }, { components });
});
test('it should render the custom component instead', () => {
expect(screen.getByText('custom caption foo')).toBeInTheDocument();
});
});
describe('when dir is ltr', () => {
const testLtr: Test[] = [
{
monthProps: {
displayIndex: 0,
displayMonth
},
dayPickerProps: {
numberOfMonths: 1,
styles: testStyles,
classNames: testClassNames
},
expected: ['caption_start', 'caption_end'],
notExpected: ['caption_between']
},
{
monthProps: {
displayIndex: 0,
displayMonth
},
dayPickerProps: {
numberOfMonths: 2,
styles: testStyles,
classNames: testClassNames
},
expected: ['caption_start'],
notExpected: ['caption_between', 'caption_end']
},
{
monthProps: {
displayIndex: 1,
displayMonth
},
dayPickerProps: {
numberOfMonths: 2,
styles: testStyles,
classNames: testClassNames
},
expected: ['caption_end'],
notExpected: ['caption_start', 'caption_between']
},
{
monthProps: {
displayIndex: 1,
displayMonth
},
dayPickerProps: {
numberOfMonths: 3,
styles: testStyles,
classNames: testClassNames
},
expected: ['caption_between'],
notExpected: ['caption_start', 'caption_end']
}
];
describe.each(testLtr)(
'when displayIndex is $monthProps.displayIndex and numberOfMonths is $dayPickerProps.numberOfMonths',
({ monthProps, dayPickerProps, expected, notExpected }) => {
beforeEach(() => {
setup(monthProps, dayPickerProps);
});
test.each(expected)(`the root should have the %s class`, (name) =>
expect(root).toHaveClass(testClassNames[name])
);
test.each(expected)(`the root should have the %s style`, (name) =>
expect(root).toHaveStyle(testStyles[name])
);
test.each(notExpected)(`the root should not have the %s class`, (name) =>
expect(root).not.toHaveClass(testClassNames[name])
);
}
);
});
describe('when dir is rtl', () => {
const testRtl: Test[] = [
{
monthProps: {
displayIndex: 0,
displayMonth
},
dayPickerProps: {
dir: 'rtl',
numberOfMonths: 1,
styles: testStyles,
classNames: testClassNames
},
expected: ['caption_start', 'caption_end'],
notExpected: ['caption_between']
},
{
monthProps: {
displayIndex: 0,
displayMonth
},
dayPickerProps: {
dir: 'rtl',
numberOfMonths: 2,
styles: testStyles,
classNames: testClassNames
},
expected: ['caption_end'],
notExpected: ['caption_between', 'caption_start']
},
{
monthProps: {
displayIndex: 1,
displayMonth
},
dayPickerProps: {
dir: 'rtl',
numberOfMonths: 2,
styles: testStyles,
classNames: testClassNames
},
expected: ['caption_start'],
notExpected: ['caption_end', 'caption_between']
},
{
monthProps: {
displayIndex: 1,
displayMonth
},
dayPickerProps: {
dir: 'rtl',
numberOfMonths: 3,
styles: testStyles,
classNames: testClassNames
},
expected: ['caption_between'],
notExpected: ['caption_start', 'caption_end']
}
];
describe.each(testRtl)(
'when displayIndex is $monthProps.displayIndex and numberOfMonths is $dayPickerProps.numberOfMonths',
({ monthProps, dayPickerProps, expected, notExpected }) => {
beforeEach(() => {
setup(monthProps, dayPickerProps);
});
test.each(expected)(`the root should have the %s class`, (name) =>
expect(root).toHaveClass(testClassNames[name])
);
test.each(expected)(`the root should have the %s style`, (name) =>
expect(root).toHaveStyle(testStyles[name])
);
test.each(notExpected)(`the root should not have the %s class`, (name) =>
expect(root).not.toHaveClass(testClassNames[name])
);
}
);
});

View File

@ -0,0 +1,66 @@
import { Caption } from 'components/Caption';
import { Table } from 'components/Table';
import { useDayPicker } from 'contexts/DayPicker';
import { useNavigation } from 'contexts/Navigation';
import { useId } from 'hooks/useId';
/** The props for the {@link Month} component. */
export interface MonthProps {
displayIndex: number;
displayMonth: Date;
}
/** Render a month. */
export function Month(props: MonthProps) {
const dayPicker = useDayPicker();
const { dir, classNames, styles, components } = dayPicker;
const { displayMonths } = useNavigation();
const captionId = useId(
dayPicker.id ? `${dayPicker.id}-${props.displayIndex}` : undefined
);
const tableId = dayPicker.id
? `${dayPicker.id}-grid-${props.displayIndex}`
: undefined;
const className = [classNames.month];
let style = styles.month;
let isStart = props.displayIndex === 0;
let isEnd = props.displayIndex === displayMonths.length - 1;
const isCenter = !isStart && !isEnd;
if (dir === 'rtl') {
[isEnd, isStart] = [isStart, isEnd];
}
if (isStart) {
className.push(classNames.caption_start);
style = { ...style, ...styles.caption_start };
}
if (isEnd) {
className.push(classNames.caption_end);
style = { ...style, ...styles.caption_end };
}
if (isCenter) {
className.push(classNames.caption_between);
style = { ...style, ...styles.caption_between };
}
const CaptionComponent = components?.Caption ?? Caption;
return (
<div key={props.displayIndex} className={className.join(' ')} style={style}>
<CaptionComponent
id={captionId}
displayMonth={props.displayMonth}
displayIndex={props.displayIndex}
/>
<Table
id={tableId}
aria-labelledby={captionId}
displayMonth={props.displayMonth}
/>
</div>
);
}

View File

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

View File

@ -0,0 +1,27 @@
import { customRender } from 'test/render';
import { Months } from './Months';
let root: HTMLElement;
test('should use the default class name', () => {
const view = customRender(<Months>foo</Months>, {});
root = view.container.firstChild as HTMLElement;
expect(root).toHaveClass('rdp-months');
});
test('should use a custom class name', () => {
const view = customRender(<Months>foo</Months>, {
classNames: { months: 'foo' }
});
root = view.container.firstChild as HTMLElement;
expect(root).toHaveClass('foo');
});
test('should use a custom style', () => {
const view = customRender(<Months>foo</Months>, {
styles: { months: { color: 'red' } }
});
root = view.container.firstChild as HTMLElement;
expect(root).toHaveStyle({ color: 'red' });
});

View File

@ -0,0 +1,19 @@
import { ReactNode } from 'react';
import { useDayPicker } from 'contexts/DayPicker';
/** The props for the {@link Months} component. */
export type MonthsProps = { children: ReactNode };
/**
* Render the wrapper for the month grids.
*/
export function Months(props: MonthsProps): JSX.Element {
const { classNames, styles } = useDayPicker();
return (
<div className={classNames.months} style={styles.months}>
{props.children}
</div>
);
}

View File

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

View File

@ -0,0 +1,107 @@
import { screen } from '@testing-library/react';
import { addMonths, differenceInMonths } from 'date-fns';
import { DayPickerProps } from 'DayPicker';
import { customRender } from 'test/render';
import { user } from 'test/user';
import { freezeBeforeAll } from 'test/utils';
import { MonthsDropdown, MonthsDropdownProps } from './MonthsDropdown';
const today = new Date(2020, 12, 22);
freezeBeforeAll(today);
let root: HTMLDivElement;
let options: HTMLCollectionOf<HTMLOptionElement> | undefined;
let select: HTMLSelectElement | null;
function setup(props: MonthsDropdownProps, dayPickerProps?: DayPickerProps) {
const view = customRender(<MonthsDropdown {...props} />, dayPickerProps);
root = view.container.firstChild as HTMLDivElement;
select = screen.queryByRole('combobox', { name: 'Month:' });
options = select?.getElementsByTagName('option');
}
const props: MonthsDropdownProps = {
displayMonth: today,
onChange: jest.fn()
};
describe('when fromDate and toDate are passed in', () => {
beforeEach(() => {
setup(props, { fromDate: new Date(), toDate: addMonths(new Date(), 1) });
});
test('should render the dropdown element', () => {
expect(root).toMatchSnapshot();
expect(select).toHaveAttribute('name', 'months');
});
});
describe('when "fromDate" is not set', () => {
beforeEach(() => {
setup(props, { fromDate: undefined });
});
test('should return nothing', () => {
expect(root).toBeNull();
});
});
describe('when "toDate" is not set', () => {
beforeEach(() => {
setup(props, { toDate: undefined });
});
test('should return nothing', () => {
expect(root).toBeNull();
});
});
describe('when "fromDate" and "toDate" are in the same year', () => {
const fromDate = new Date(2012, 0, 22);
const toDate = new Date(2012, 10, 22);
beforeEach(() => {
setup(props, { fromDate, toDate });
});
test('should display the months included between the two dates', () => {
expect(options).toHaveLength(differenceInMonths(toDate, fromDate) + 1);
});
test('the first month should be the fromDate month', () => {
expect(options?.[0]).toHaveValue(String(fromDate.getMonth()));
});
test('the last month should be the toMonth month', () => {
expect(options?.[options.length - 1]).toHaveValue(
String(toDate.getMonth())
);
});
});
describe('when "fromDate" and "toDate" are not in the same year', () => {
const fromDate = new Date(2012, 0, 22);
const toDate = new Date(2015, 10, 22);
const displayMonth = new Date(2015, 7, 0);
beforeEach(() => {
setup({ ...props, displayMonth }, { fromDate, toDate });
});
test('should display the 12 months', () => {
expect(options).toHaveLength(12);
});
test('the first month should be January', () => {
expect(options?.[0]).toHaveValue('0');
});
test('the last month should be December', () => {
expect(options?.[options.length - 1]).toHaveValue('11');
});
test('should select the displayed month', () => {
expect(select).toHaveValue(`${displayMonth.getMonth()}`);
});
describe('when the dropdown changes', () => {
beforeEach(async () => {
if (select) await user.selectOptions(select, 'February');
});
test('should fire the "onChange" event handler', () => {
const expectedMonth = new Date(2015, 1, 1);
expect(props.onChange).toHaveBeenCalledWith(expectedMonth);
});
});
});

View File

@ -0,0 +1,74 @@
import { ChangeEventHandler } from 'react';
import { isSameYear, setMonth, startOfMonth } from 'date-fns';
import { Dropdown } from 'components/Dropdown';
import { useDayPicker } from 'contexts/DayPicker';
import { MonthChangeEventHandler } from 'types/EventHandlers';
/** The props for the {@link MonthsDropdown} component. */
export interface MonthsDropdownProps {
/** The month where the dropdown is displayed. */
displayMonth: Date;
onChange: MonthChangeEventHandler;
}
/** Render the dropdown to navigate between months. */
export function MonthsDropdown(props: MonthsDropdownProps): JSX.Element {
const {
fromDate,
toDate,
styles,
locale,
formatters: { formatMonthCaption },
classNames,
components,
labels: { labelMonthDropdown }
} = useDayPicker();
// Dropdown should appear only when both from/toDate is set
if (!fromDate) return <></>;
if (!toDate) return <></>;
const dropdownMonths: Date[] = [];
if (isSameYear(fromDate, toDate)) {
// only display the months included in the range
const date = startOfMonth(fromDate);
for (let month = fromDate.getMonth(); month <= toDate.getMonth(); month++) {
dropdownMonths.push(setMonth(date, month));
}
} else {
// display all the 12 months
const date = startOfMonth(new Date()); // Any date should be OK, as we just need the year
for (let month = 0; month <= 11; month++) {
dropdownMonths.push(setMonth(date, month));
}
}
const handleChange: ChangeEventHandler<HTMLSelectElement> = (e) => {
const selectedMonth = Number(e.target.value);
const newMonth = setMonth(startOfMonth(props.displayMonth), selectedMonth);
props.onChange(newMonth);
};
const DropdownComponent = components?.Dropdown ?? Dropdown;
return (
<DropdownComponent
name="months"
aria-label={labelMonthDropdown()}
className={classNames.dropdown_month}
style={styles.dropdown_month}
onChange={handleChange}
value={props.displayMonth.getMonth()}
caption={formatMonthCaption(props.displayMonth, { locale })}
>
{dropdownMonths.map((m) => (
<option key={m.getMonth()} value={m.getMonth()}>
{formatMonthCaption(m, { locale })}
</option>
))}
</DropdownComponent>
);
}

View File

@ -0,0 +1,48 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`when fromDate and toDate are passed in should render the dropdown element 1`] = `
<div
class="rdp-dropdown_month"
>
<span
class="rdp-vhidden"
>
Month:
</span>
<select
aria-label="Month: "
class="rdp-dropdown"
name="months"
>
<option
value="0"
>
January
</option>
<option
value="1"
>
February
</option>
</select>
<div
aria-hidden="true"
class="rdp-caption_label"
>
January
<svg
class="rdp-dropdown_icon"
data-testid="iconDropdown"
height="8px"
viewBox="0 0 120 120"
width="8px"
>
<path
d="M4.22182541,48.2218254 C8.44222828,44.0014225 15.2388494,43.9273804 19.5496459,47.9996989 L19.7781746,48.2218254 L60,88.443 L100.221825,48.2218254 C104.442228,44.0014225 111.238849,43.9273804 115.549646,47.9996989 L115.778175,48.2218254 C119.998577,52.4422283 120.07262,59.2388494 116.000301,63.5496459 L115.778175,63.7781746 L67.7781746,111.778175 C63.5577717,115.998577 56.7611506,116.07262 52.4503541,112.000301 L52.2218254,111.778175 L4.22182541,63.7781746 C-0.0739418023,59.4824074 -0.0739418023,52.5175926 4.22182541,48.2218254 Z"
fill="currentColor"
fill-rule="nonzero"
/>
</svg>
</div>
</div>
`;

View File

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

View File

@ -0,0 +1,133 @@
import { DayPickerProps } from 'DayPicker';
import { customRender } from 'test/render';
import { getNextButton, getPrevButton } from 'test/selectors';
import { user } from 'test/user';
import { Navigation, NavigationProps } from './Navigation';
let root: HTMLElement;
function setup(props: NavigationProps, dayPickerProps?: DayPickerProps) {
const view = customRender(<Navigation {...props} />, dayPickerProps);
root = view.container.firstChild as HTMLElement;
}
const props: NavigationProps = {
previousMonth: new Date(2021, 3),
nextMonth: new Date(2021, 5),
displayMonth: new Date(2021, 4),
hidePrevious: false,
hideNext: false,
onNextClick: jest.fn(),
onPreviousClick: jest.fn()
};
const dayPickerProps = {
classNames: {
nav: 'foo'
},
styles: {
nav: { color: 'red' }
},
components: {
IconRight: () => <svg>IconRight</svg>,
IconLeft: () => <svg>IconLeft</svg>
}
};
describe('when rendered', () => {
beforeEach(() => {
setup(props, dayPickerProps);
});
test('should add the class name', () => {
expect(root).toHaveClass(dayPickerProps.classNames.nav);
});
test('should apply the style', () => {
expect(root).toHaveStyle(dayPickerProps.styles.nav);
});
test('the previous button should display the left icon', () => {
const icons = root.getElementsByTagName('svg');
expect(icons[0]).toHaveTextContent('IconLeft');
});
test('the next button should display the right icon', () => {
const icons = root.getElementsByTagName('svg');
expect(icons[1]).toHaveTextContent('IconRight');
});
test('the previous button should be named "previous-month"', () => {
expect(getPrevButton()).toHaveAttribute('name', 'previous-month');
});
test('the next button should be named "next-month"', () => {
expect(getNextButton()).toHaveAttribute('name', 'next-month');
});
beforeEach(async () => {
await user.click(getPrevButton());
});
test('should call "onPreviousClick"', () => {
expect(props.onPreviousClick).toHaveBeenCalled();
});
describe('when clicking the next button', () => {
beforeEach(async () => {
await user.click(getNextButton());
});
test('should call "onNextClick"', () => {
expect(props.onNextClick).toHaveBeenCalled();
});
});
});
describe('when in right-to-left direction', () => {
beforeEach(() => {
setup(props, { ...dayPickerProps, dir: 'rtl' });
});
test('the previous button should display the right icon', () => {
const icons = root.getElementsByTagName('svg');
expect(icons[0]).toHaveTextContent('IconRight');
});
test('the next button should display the left icon', () => {
const icons = root.getElementsByTagName('svg');
expect(icons[1]).toHaveTextContent('IconLeft');
});
describe('when clicking the previous button', () => {
beforeEach(async () => {
await user.click(getPrevButton());
});
test('should call "onPreviousClick"', () => {
expect(props.onPreviousClick).toHaveBeenCalled();
});
});
describe('when clicking the next button', () => {
beforeEach(async () => {
await user.click(getNextButton());
});
test('should call "onNextClick"', () => {
expect(props.onNextClick).toHaveBeenCalled();
});
});
});
describe('when the previous month is undefined', () => {
beforeEach(() => {
setup({ ...props, previousMonth: undefined }, dayPickerProps);
});
test('the previous button should be disabled', () => {
expect(getPrevButton()).toBeDisabled();
});
test('the next button should be enabled', () => {
expect(getNextButton()).toBeEnabled();
});
});
describe('when the next month is undefined', () => {
beforeEach(() => {
setup({ ...props, nextMonth: undefined }, dayPickerProps);
});
test('the previous button should be enabled', () => {
expect(getPrevButton()).toBeEnabled();
});
test('the next button should be disabled', () => {
expect(getNextButton()).toBeDisabled();
});
});

View File

@ -0,0 +1,104 @@
import { MouseEventHandler } from 'react';
import { IconLeft } from 'components/IconLeft';
import { IconRight } from 'components/IconRight';
import { useDayPicker } from 'contexts/DayPicker';
import { Button } from '../Button';
/** The props for the {@link Navigation} component. */
export interface NavigationProps {
/** The month where the caption is displayed. */
displayMonth: Date;
/** The previous month. */
previousMonth?: Date;
/** The next month. */
nextMonth?: Date;
/** Hide the previous button. */
hidePrevious: boolean;
/** Hide the next button. */
hideNext: boolean;
/** Event handler when the next button is clicked. */
onNextClick: MouseEventHandler<HTMLButtonElement>;
/** Event handler when the previous button is clicked. */
onPreviousClick: MouseEventHandler<HTMLButtonElement>;
}
/** A component rendering the navigation buttons or the drop-downs. */
export function Navigation(props: NavigationProps): JSX.Element {
const {
dir,
locale,
classNames,
styles,
labels: { labelPrevious, labelNext },
components
} = useDayPicker();
if (!props.nextMonth && !props.previousMonth) {
return <></>;
}
const previousLabel = labelPrevious(props.previousMonth, { locale });
const previousClassName = [
classNames.nav_button,
classNames.nav_button_previous
].join(' ');
const nextLabel = labelNext(props.nextMonth, { locale });
const nextClassName = [
classNames.nav_button,
classNames.nav_button_next
].join(' ');
const IconRightComponent = components?.IconRight ?? IconRight;
const IconLeftComponent = components?.IconLeft ?? IconLeft;
return (
<div className={classNames.nav} style={styles.nav}>
{!props.hidePrevious && (
<Button
name="previous-month"
aria-label={previousLabel}
className={previousClassName}
style={styles.nav_button_previous}
disabled={!props.previousMonth}
onClick={props.onPreviousClick}
>
{dir === 'rtl' ? (
<IconRightComponent
className={classNames.nav_icon}
style={styles.nav_icon}
/>
) : (
<IconLeftComponent
className={classNames.nav_icon}
style={styles.nav_icon}
/>
)}
</Button>
)}
{!props.hideNext && (
<Button
name="next-month"
aria-label={nextLabel}
className={nextClassName}
style={styles.nav_button_next}
disabled={!props.nextMonth}
onClick={props.onNextClick}
>
{dir === 'rtl' ? (
<IconLeftComponent
className={classNames.nav_icon}
style={styles.nav_icon}
/>
) : (
<IconRightComponent
className={classNames.nav_icon}
style={styles.nav_icon}
/>
)}
</Button>
)}
</div>
);
}

View File

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

View File

@ -0,0 +1,173 @@
import { RenderResult, screen } from '@testing-library/react';
import { addDays } from 'date-fns';
import { DayPickerProps } from 'DayPicker';
import { customRender } from 'test/render';
import { getDayButton, queryMonthGrids } from 'test/selectors';
import { freezeBeforeAll } from 'test/utils';
import { MonthsProps } from 'components/Months';
import { defaultClassNames } from 'contexts/DayPicker/defaultClassNames';
import { ClassNames } from 'types/Styles';
import { Root } from './Root';
const today = new Date(2020, 10, 4);
freezeBeforeAll(today);
let container: HTMLElement;
let view: RenderResult;
function render(dayPickerProps: DayPickerProps = {}) {
view = customRender(<Root initialProps={dayPickerProps} />, dayPickerProps);
container = view.container;
}
describe('when the number of months is 1', () => {
const props: DayPickerProps = { numberOfMonths: 1 };
beforeEach(() => {
render(props);
});
test('should display one month grid', () => {
expect(queryMonthGrids()).toHaveLength(1);
});
});
describe('when the number of months is greater than 1', () => {
const props: DayPickerProps = { numberOfMonths: 3 };
beforeEach(() => {
render(props);
});
test('should display the specified number of month grids', () => {
expect(queryMonthGrids()).toHaveLength(3);
});
});
describe('when using the "classNames" prop', () => {
const classNames: ClassNames = {
root: 'foo'
};
beforeEach(() => {
render({ classNames });
});
test('should add the class to the container', () => {
expect(container.firstChild).toHaveClass('foo');
});
});
describe('when using a custom "Months" component', () => {
function CustomMonths(props: MonthsProps) {
return (
<div>
<div data-testid="foo" />
{props.children}
</div>
);
}
beforeEach(() => {
render({ numberOfMonths: 3, components: { Months: CustomMonths } });
});
test('should render the custom component', () => {
expect(screen.getByTestId('foo')).toBeInTheDocument();
});
test('should still display the specified number of months', () => {
expect(queryMonthGrids()).toHaveLength(3);
});
});
describe('when using the "id" prop', () => {
const testId = 'foo';
beforeEach(() => render({ id: testId }));
test('should add the "id" attribute', () => {
expect(container.firstChild).toHaveAttribute('id', testId);
});
});
describe('when using the "nonce" prop', () => {
const nonce = 'foo';
beforeEach(() => render({ nonce }));
test('should add the "nonce" attribute', () => {
expect(container.firstChild).toHaveAttribute('nonce', nonce);
});
});
describe('when using the "title" prop', () => {
const title = 'foo';
beforeEach(() => render({ title }));
test('should add the "title" attribute', () => {
expect(container.firstChild).toHaveAttribute('title', title);
});
});
describe('when using the "lang" prop', () => {
const lang = 'en-US';
beforeEach(() => render({ lang }));
test('should add the "lang" attribute', () => {
expect(container.firstChild).toHaveAttribute('lang', lang);
});
});
describe('when using the "className" prop', () => {
const props: DayPickerProps = { className: 'foo' };
beforeEach(() => {
render(props);
});
test('should append the class name to the root element', () => {
expect(container.firstChild).toHaveClass('rdp foo');
});
});
describe('when the "numberOfMonths" is greater than 1', () => {
const props: DayPickerProps = { numberOfMonths: 3 };
const expectedClassName = defaultClassNames.multiple_months;
beforeEach(() => {
render(props);
});
test(`should have the ${expectedClassName} class name`, () => {
expect(container.firstChild).toHaveClass(expectedClassName);
});
});
describe('when showing the week numbers', () => {
const props: DayPickerProps = { showWeekNumber: true };
const expectedClassName = defaultClassNames.with_weeknumber;
beforeEach(() => {
render(props);
});
test(`should have the ${expectedClassName} class name`, () => {
expect(container.firstChild).toHaveClass(expectedClassName);
});
});
describe('when "initialFocus" is set', () => {
const baseProps: DayPickerProps = {
initialFocus: true,
mode: 'single'
};
describe('when a day is not selected', () => {
beforeEach(() => {
render(baseProps);
});
test('should focus today', () => {
expect(getDayButton(today)).toHaveFocus();
});
describe('when a new day is focused', () => {
beforeEach(() => {
getDayButton(addDays(today, 1)).focus();
});
describe('and the calendar is rerendered', () => {
test.todo('should focus the new day');
});
});
});
describe('when a day is selected', () => {
const selected = addDays(today, 1);
const props: DayPickerProps = { ...baseProps, selected };
beforeEach(() => {
render(props);
});
test('should focus the selected day', () => {
expect(getDayButton(selected)).toHaveFocus();
});
});
});

View File

@ -0,0 +1,89 @@
import { useEffect, useState } from 'react';
import { DayPickerProps } from 'DayPicker';
import { Month } from 'components/Month';
import { Months } from 'components/Months';
import { useDayPicker } from 'contexts/DayPicker';
import { useFocusContext } from 'contexts/Focus';
import { useNavigation } from 'contexts/Navigation';
function isDataAttributes(attrs: DayPickerProps): attrs is {
[key: string]: string | boolean | number | undefined;
} {
return true;
}
export interface RootProps {
initialProps: DayPickerProps;
}
/** Render the container with the months according to the number of months to display. */
export function Root({ initialProps }: RootProps): JSX.Element {
const dayPicker = useDayPicker();
const focusContext = useFocusContext();
const navigation = useNavigation();
const [hasInitialFocus, setHasInitialFocus] = useState(false);
// Focus the focus target when initialFocus is passed in
useEffect(() => {
if (!dayPicker.initialFocus) return;
if (!focusContext.focusTarget) return;
if (hasInitialFocus) return;
focusContext.focus(focusContext.focusTarget);
setHasInitialFocus(true);
}, [
dayPicker.initialFocus,
hasInitialFocus,
focusContext.focus,
focusContext.focusTarget,
focusContext
]);
// Apply classnames according to props
const classNames = [dayPicker.classNames.root, dayPicker.className];
if (dayPicker.numberOfMonths > 1) {
classNames.push(dayPicker.classNames.multiple_months);
}
if (dayPicker.showWeekNumber) {
classNames.push(dayPicker.classNames.with_weeknumber);
}
const style = {
...dayPicker.styles.root,
...dayPicker.style
};
const dataAttributes = Object.keys(initialProps)
.filter((key) => key.startsWith('data-'))
.reduce((attrs, key) => {
if (!isDataAttributes(initialProps)) return attrs;
return {
...attrs,
[key]: initialProps[key]
};
}, {});
const MonthsComponent = initialProps.components?.Months ?? Months;
return (
<div
className={classNames.join(' ')}
style={style}
dir={dayPicker.dir}
id={dayPicker.id}
nonce={initialProps.nonce}
title={initialProps.title}
lang={initialProps.lang}
{...dataAttributes}
>
<MonthsComponent>
{navigation.displayMonths.map((month, i) => (
<Month key={i} displayIndex={i} displayMonth={month} />
))}
</MonthsComponent>
</div>
);
}

View File

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

View File

@ -0,0 +1,67 @@
import { screen } from '@testing-library/react';
import { DayPickerProps } from 'DayPicker';
import { customRender } from 'test/render/customRender';
import { CustomComponents } from 'types/DayPickerBase';
import { Row, RowProps } from './Row';
function setup(props: RowProps, dayPickerProps?: DayPickerProps) {
customRender(<Row {...props} />, dayPickerProps);
}
const props: RowProps = {
displayMonth: new Date(2020, 1),
weekNumber: 4,
dates: [new Date(2020, 1, 1), new Date(2020, 1, 2), new Date(2020, 1, 3)]
};
describe('when "showWeekNumber" is set', () => {
const dayPickerProps = {
showWeekNumber: true,
classNames: { cell: 'cell' },
styles: { cell: { background: 'red' } }
};
beforeEach(() => {
setup(props, dayPickerProps);
});
test('should display the cell with the week number', () => {
const cell = screen.getByRole('cell', { name: `${props.weekNumber}` });
expect(cell).toBeInTheDocument();
});
test('the cell should have the "cell" class name', () => {
const cell = screen.getByRole('cell', { name: `${props.weekNumber}` });
expect(cell).toHaveClass(dayPickerProps.classNames.cell);
});
test('the cell should have the "cell" style', () => {
const cell = screen.getByRole('cell', { name: `${props.weekNumber}` });
expect(cell).toHaveStyle(dayPickerProps.styles.cell);
});
});
describe('when using a custom Day component', () => {
const components: CustomComponents = {
Day: () => <div>CustomDay</div>
};
const dayPickerProps = { components };
beforeEach(() => {
setup(props, dayPickerProps);
});
test('it should render the custom component instead', () => {
expect(screen.getAllByText('CustomDay')).toHaveLength(props.dates.length);
});
});
describe('when using a custom WeekNumber component', () => {
const components: CustomComponents = {
WeekNumber: () => <div>WeekNumber</div>
};
const dayPickerProps: DayPickerProps = { components, showWeekNumber: true };
beforeEach(() => {
setup(props, dayPickerProps);
});
test('it should render the custom component instead', () => {
expect(screen.getByText('WeekNumber')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,50 @@
import { getUnixTime } from 'date-fns';
import { Day } from 'components/Day';
import { WeekNumber } from 'components/WeekNumber';
import { useDayPicker } from 'contexts/DayPicker';
/**
* The props for the {@link Row} component.
*/
export interface RowProps {
/** The month where the row is displayed. */
displayMonth: Date;
/** The number of the week to render. */
weekNumber: number;
/** The days contained in the week. */
dates: Date[];
}
/** Render a row in the calendar, with the days and the week number. */
export function Row(props: RowProps): JSX.Element {
const { styles, classNames, showWeekNumber, components } = useDayPicker();
const DayComponent = components?.Day ?? Day;
const WeeknumberComponent = components?.WeekNumber ?? WeekNumber;
let weekNumberCell;
if (showWeekNumber) {
weekNumberCell = (
<td className={classNames.cell} style={styles.cell}>
<WeeknumberComponent number={props.weekNumber} dates={props.dates} />
</td>
);
}
return (
<tr className={classNames.row} style={styles.row}>
{weekNumberCell}
{props.dates.map((date) => (
<td
className={classNames.cell}
style={styles.cell}
key={getUnixTime(date)}
role="presentation"
>
<DayComponent displayMonth={props.displayMonth} date={date} />
</td>
))}
</tr>
);
}

View File

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

View File

@ -0,0 +1,62 @@
import { DayPickerProps } from 'DayPicker';
import { customRender } from 'test/render/customRender';
import { freezeBeforeAll } from 'test/utils';
import { FooterProps } from 'components/Footer';
import { Table, TableProps } from './Table';
function setup(props: TableProps, dayPickerProps?: DayPickerProps) {
return customRender(<Table {...props} />, dayPickerProps);
}
const today = new Date(2021, 11, 8);
freezeBeforeAll(today);
const props: TableProps = {
displayMonth: new Date(2020, 1)
};
test('should render correctly', () => {
const { container } = setup(props);
expect(container.firstChild).toMatchSnapshot();
});
describe('when showing the week numbers', () => {
const dayPickerProps = { showWeekNumber: true };
test('should render correctly', () => {
const { container } = setup(props, dayPickerProps);
expect(container.firstChild).toMatchSnapshot();
});
});
describe('when using custom components', () => {
const dayPickerProps: DayPickerProps = {
components: {
Head: () => (
<thead>
<tr>
<td>CustomHead</td>
</tr>
</thead>
),
Row: () => (
<tr>
<td>CustomRow</td>
</tr>
),
Footer: (props: FooterProps) => (
<tfoot>
<tr>
<td>{props.displayMonth?.toDateString()}</td>
</tr>
</tfoot>
)
}
};
test('should render correctly', () => {
const { container } = setup(props, dayPickerProps);
expect(container.firstChild).toMatchSnapshot();
});
});

View File

@ -0,0 +1,65 @@
import { Footer } from 'components/Footer';
import { Head } from 'components/Head';
import { Row } from 'components/Row';
import { useDayPicker } from 'contexts/DayPicker';
import { getMonthWeeks } from './utils/getMonthWeeks';
/** The props for the {@link Table} component. */
export interface TableProps {
/** ID of table element */
id?: string;
/** The ID of the label of the table (the same given to the Caption). */
['aria-labelledby']?: string;
/** The month where the table is displayed. */
displayMonth: Date;
}
/** Render the table with the calendar. */
export function Table(props: TableProps): JSX.Element {
const {
locale,
classNames,
styles,
hideHead,
fixedWeeks,
components,
weekStartsOn,
firstWeekContainsDate,
ISOWeek
} = useDayPicker();
const weeks = getMonthWeeks(props.displayMonth, {
useFixedWeeks: Boolean(fixedWeeks),
ISOWeek,
locale,
weekStartsOn,
firstWeekContainsDate
});
const HeadComponent = components?.Head ?? Head;
const RowComponent = components?.Row ?? Row;
const FooterComponent = components?.Footer ?? Footer;
return (
<table
id={props.id}
className={classNames.table}
style={styles.table}
role="grid"
aria-labelledby={props['aria-labelledby']}
>
{!hideHead && <HeadComponent />}
<tbody className={classNames.tbody} style={styles.tbody}>
{weeks.map((week) => (
<RowComponent
displayMonth={props.displayMonth}
key={week.weekNumber}
dates={week.dates}
weekNumber={week.weekNumber}
/>
))}
</tbody>
<FooterComponent displayMonth={props.displayMonth} />
</table>
);
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -0,0 +1,60 @@
import {
addDays,
differenceInCalendarDays,
endOfISOWeek,
endOfWeek,
getISOWeek,
getWeek,
Locale,
startOfISOWeek,
startOfWeek
} from 'date-fns';
import { MonthWeek } from './getMonthWeeks';
/** Return the weeks between two dates. */
export function daysToMonthWeeks(
fromDate: Date,
toDate: Date,
options?: {
ISOWeek?: boolean;
locale?: Locale;
weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
firstWeekContainsDate?: 1 | 4;
}
): MonthWeek[] {
const toWeek = options?.ISOWeek
? endOfISOWeek(toDate)
: endOfWeek(toDate, options);
const fromWeek = options?.ISOWeek
? startOfISOWeek(fromDate)
: startOfWeek(fromDate, options);
const nOfDays = differenceInCalendarDays(toWeek, fromWeek);
const days: Date[] = [];
for (let i = 0; i <= nOfDays; i++) {
days.push(addDays(fromWeek, i));
}
const weeksInMonth = days.reduce((result: MonthWeek[], date) => {
const weekNumber = options?.ISOWeek
? getISOWeek(date)
: getWeek(date, options);
const existingWeek = result.find(
(value) => value.weekNumber === weekNumber
);
if (existingWeek) {
existingWeek.dates.push(date);
return result;
}
result.push({
weekNumber,
dates: [date]
});
return result;
}, []);
return weeksInMonth;
}

View File

@ -0,0 +1,100 @@
import { enGB, enUS } from 'date-fns/locale';
import { getMonthWeeks } from './getMonthWeeks';
describe('when using the "enUS" locale', () => {
const locale = enUS;
describe('when using fixed weeks', () => {
const useFixedWeeks = true;
describe('when getting the weeks for December 2022', () => {
const date = new Date(2022, 11);
const weeks = getMonthWeeks(date, { useFixedWeeks, locale });
test('should return 49 - 1 week numbers', () => {
const weekNumbers = weeks.map((week) => week.weekNumber);
const expectedResult = [49, 50, 51, 52, 53, 1];
expect(weekNumbers).toEqual(expectedResult);
});
test('the last week should be the one in the next year', () => {
const lastWeek = weeks[weeks.length - 1];
const lastWeekDates = lastWeek.dates.map((date) => date.getDate());
const expectedResult = [1, 2, 3, 4, 5, 6, 7];
expect(lastWeekDates).toEqual(expectedResult);
});
});
describe('when getting the weeks for December 2021', () => {
const weeks = getMonthWeeks(new Date(2021, 11), {
useFixedWeeks: false,
locale: enUS
});
test('should return 49 - 1 week numbers', () => {
const weekNumbers = weeks.map((week) => week.weekNumber);
const expectedResult = [49, 50, 51, 52, 1];
expect(weekNumbers).toEqual(expectedResult);
});
test('the last week should be the last in the year', () => {
const lastWeek = weeks[weeks.length - 1];
const lastWeekDates = lastWeek.dates.map((date) => date.getDate());
const expectedResult = [26, 27, 28, 29, 30, 31, 1];
expect(lastWeekDates).toEqual(expectedResult);
});
test('week 1 contains the first day of the new year', () => {
expect(weeks[4].dates.map((date) => date.getDate())).toEqual([
26, 27, 28, 29, 30, 31, 1
]);
});
});
});
});
describe('when using the "enGB" locale', () => {
const locale = enGB;
describe('when getting the weeks for January 2022', () => {
const date = new Date(2022, 0);
const weeks = getMonthWeeks(date, { locale });
test('the first week should be the last of the previous year', () => {
const weekNumbers = weeks.map((week) => week.weekNumber);
expect(weekNumbers[0]).toEqual(52);
});
test('the first week should contain days from previous year', () => {
expect(weeks[0].dates.map((date) => date.getDate())).toEqual([
27, 28, 29, 30, 31, 1, 2
]);
});
test('the last week should be the last of January', () => {
const weekNumbers = weeks.map((week) => week.weekNumber);
expect(weekNumbers[weekNumbers.length - 1]).toEqual(5);
});
});
describe('when setting thursday as first day of year', () => {
const date = new Date(2022, 0);
const weeks = getMonthWeeks(date, { locale, firstWeekContainsDate: 4 });
test('the number of week should have number 52', () => {
const weekNumbers = weeks.map((week) => week.weekNumber);
expect(weekNumbers[0]).toEqual(52);
});
});
});
describe('when using the ISOWeek numbers', () => {
const locale = enUS;
describe('when getting the weeks for September 2022', () => {
const date = new Date(2022, 8);
const weeks = getMonthWeeks(date, { locale, ISOWeek: true });
test('the last week should have number 39', () => {
const weekNumbers = weeks.map((week) => week.weekNumber);
expect(weekNumbers[weekNumbers.length - 1]).toEqual(39);
});
});
});
describe('when not using the ISOWeek numbers', () => {
const locale = enUS;
describe('when getting the weeks for September 2022', () => {
const date = new Date(2022, 8);
const weeks = getMonthWeeks(date, { locale, ISOWeek: false });
test('the last week should have number 40', () => {
const weekNumbers = weeks.map((week) => week.weekNumber);
expect(weekNumbers[weekNumbers.length - 1]).toEqual(40);
});
});
});

View File

@ -0,0 +1,55 @@
import {
addWeeks,
endOfMonth,
getWeeksInMonth,
Locale,
startOfMonth
} from 'date-fns';
import { daysToMonthWeeks } from './daysToMonthWeeks';
/** Represents a week in the month.*/
export type MonthWeek = {
/** The week number from the start of the year. */
weekNumber: number;
/** The dates in the week. */
dates: Date[];
};
/**
* Return the weeks belonging to the given month, adding the "outside days" to
* the first and last week.
*/
export function getMonthWeeks(
month: Date,
options: {
locale: Locale;
useFixedWeeks?: boolean;
weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
firstWeekContainsDate?: 1 | 4;
ISOWeek?: boolean;
}
): MonthWeek[] {
const weeksInMonth: MonthWeek[] = daysToMonthWeeks(
startOfMonth(month),
endOfMonth(month),
options
);
if (options?.useFixedWeeks) {
// Add extra weeks to the month, up to 6 weeks
const nrOfMonthWeeks = getWeeksInMonth(month, options);
if (nrOfMonthWeeks < 6) {
const lastWeek = weeksInMonth[weeksInMonth.length - 1];
const lastDate = lastWeek.dates[lastWeek.dates.length - 1];
const toDate = addWeeks(lastDate, 6 - nrOfMonthWeeks);
const extraWeeks = daysToMonthWeeks(
addWeeks(lastDate, 1),
toDate,
options
);
weeksInMonth.push(...extraWeeks);
}
}
return weeksInMonth;
}

View File

@ -0,0 +1,49 @@
import { screen } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import { DayPickerProps } from 'DayPicker';
import { customRender } from 'test/render/customRender';
import { WeekNumber, WeekNumberProps } from './WeekNumber';
function setup(props: WeekNumberProps, dayPickerProps?: DayPickerProps) {
return customRender(<WeekNumber {...props} />, dayPickerProps);
}
const props: WeekNumberProps = {
number: 10,
dates: [new Date(), new Date()]
};
describe('without "onWeekNumberClick" prop', () => {
const dayPickerProps: DayPickerProps = { onWeekNumberClick: undefined };
test('it should return a span element', () => {
const { container } = setup(props, dayPickerProps);
expect(container.firstChild).toMatchSnapshot();
});
});
describe('with "onWeekNumberClick" prop', () => {
const dayPickerProps: DayPickerProps = { onWeekNumberClick: jest.fn() };
let container: HTMLElement;
beforeEach(() => {
container = setup(props, dayPickerProps).container;
});
test('it should return a button element', () => {
expect(screen.getByRole('button')).toBeInTheDocument();
expect(container.firstChild).toHaveAttribute('name', 'week-number');
expect(container.firstChild).toMatchSnapshot();
});
describe('when the button element is clicked', () => {
beforeEach(async () => {
await userEvent.click(screen.getByRole('button'));
});
test('should call onWeekNumberClick', () => {
expect(dayPickerProps.onWeekNumberClick).toHaveBeenCalledWith(
props.number,
props.dates,
expect.anything()
);
});
});
});

View File

@ -0,0 +1,59 @@
import { MouseEventHandler } from 'react';
import { useDayPicker } from 'contexts/DayPicker';
import { Button } from '../Button';
/**
* The props for the {@link WeekNumber} component.
*/
export interface WeekNumberProps {
/** The number of the week. */
number: number;
/** The dates in the week. */
dates: Date[];
}
/**
* Render the week number element. If `onWeekNumberClick` is passed to DayPicker, it
* renders a button, otherwise a span element.
*/
export function WeekNumber(props: WeekNumberProps): JSX.Element {
const { number: weekNumber, dates } = props;
const {
onWeekNumberClick,
styles,
classNames,
locale,
labels: { labelWeekNumber },
formatters: { formatWeekNumber }
} = useDayPicker();
const content = formatWeekNumber(Number(weekNumber), { locale });
if (!onWeekNumberClick) {
return (
<span className={classNames.weeknumber} style={styles.weeknumber}>
{content}
</span>
);
}
const label = labelWeekNumber(Number(weekNumber), { locale });
const handleClick: MouseEventHandler = function (e) {
onWeekNumberClick(weekNumber, dates, e);
};
return (
<Button
name="week-number"
aria-label={label}
className={classNames.weeknumber}
style={styles.weeknumber}
onClick={handleClick}
>
{content}
</Button>
);
}

View File

@ -0,0 +1,20 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`with "onWeekNumberClick" prop it should return a button element 1`] = `
<button
aria-label="Week n. 10"
class="rdp-button_reset rdp-button rdp-weeknumber"
name="week-number"
type="button"
>
10
</button>
`;
exports[`without "onWeekNumberClick" prop it should return a span element 1`] = `
<span
class="rdp-weeknumber"
>
10
</span>
`;

View File

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

View File

@ -0,0 +1,106 @@
import { screen } from '@testing-library/react';
import { addMonths, differenceInYears } from 'date-fns';
import { DayPickerProps } from 'DayPicker';
import { customRender } from 'test/render';
import { user } from 'test/user';
import { freezeBeforeAll } from 'test/utils';
import { YearsDropdown, YearsDropdownProps } from './YearsDropdown';
const today = new Date(2020, 12, 22);
freezeBeforeAll(today);
let root: HTMLDivElement;
let options: HTMLCollectionOf<HTMLOptionElement> | undefined;
let select: HTMLSelectElement | null;
function setup(props: YearsDropdownProps, dayPickerProps?: DayPickerProps) {
const view = customRender(<YearsDropdown {...props} />, dayPickerProps);
root = view.container.firstChild as HTMLDivElement;
select = screen.queryByRole('combobox', { name: 'Year:' });
options = select?.getElementsByTagName('option');
}
const props: YearsDropdownProps = {
displayMonth: today,
onChange: jest.fn()
};
describe('when fromDate and toDate are passed in', () => {
beforeEach(() => {
setup(props, { fromDate: new Date(), toDate: addMonths(new Date(), 1) });
});
test('should render the dropdown element', () => {
expect(root).toMatchSnapshot();
expect(select).toHaveAttribute('name', 'years');
});
});
describe('when "fromDate" is not set', () => {
beforeEach(() => {
setup(props, { fromDate: undefined });
});
test('should return nothing', () => {
expect(root).toBeNull();
});
});
describe('when "toDate" is not set', () => {
beforeEach(() => {
setup(props, { toDate: undefined });
});
test('should return nothing', () => {
expect(root).toBeNull();
});
});
describe('when "fromDate" and "toDate" are in the same year', () => {
const fromDate = new Date(2012, 0, 22);
const toDate = new Date(2012, 10, 22);
beforeEach(() => {
setup(props, { fromDate, toDate });
});
test('should display the months included between the two dates', () => {
expect(select).toBeInTheDocument();
expect(options).toHaveLength(differenceInYears(toDate, fromDate) + 1);
});
test('the month should be the same month', () => {
expect(options?.[0]).toHaveValue(`${fromDate.getFullYear()}`);
});
});
describe('when "fromDate" and "toDate" are not in the same year', () => {
const fromDate = new Date(2012, 0, 22);
const toDate = new Date(2015, 10, 22);
const displayMonth = new Date(2013, 7, 0);
beforeEach(() => {
setup({ ...props, displayMonth }, { fromDate, toDate });
});
test('should display the full years', () => {
expect(options).toHaveLength(differenceInYears(toDate, fromDate) + 1);
});
test('the first option should be fromDates year', () => {
expect(options?.[0]).toHaveValue(`${fromDate.getFullYear()}`);
});
test('the last option should be "toDate"s year', () => {
expect(options?.[options.length - 1]).toHaveValue(
`${toDate.getFullYear()}`
);
});
test('should select the displayed year', () => {
expect(select).toHaveValue(`${displayMonth.getFullYear()}`);
});
describe('when the dropdown changes', () => {
const newYear = fromDate.getFullYear();
beforeEach(async () => {
if (select) await user.selectOptions(select, `${newYear}`);
});
test('should fire the "onChange" event handler', () => {
const expectedYear = new Date(newYear, displayMonth.getMonth(), 1);
expect(props.onChange).toHaveBeenCalledWith(expectedYear);
});
});
});

View File

@ -0,0 +1,75 @@
import { ChangeEventHandler } from 'react';
import { setYear, startOfMonth, startOfYear } from 'date-fns';
import { Dropdown } from 'components/Dropdown';
import { useDayPicker } from 'contexts/DayPicker';
import { MonthChangeEventHandler } from 'types/EventHandlers';
/**
* The props for the {@link YearsDropdown} component.
*/
export interface YearsDropdownProps {
/** The month where the drop-down is displayed. */
displayMonth: Date;
/** Callback to handle the `change` event. */
onChange: MonthChangeEventHandler;
}
/**
* Render a dropdown to change the year. Take in account the `nav.fromDate` and
* `toDate` from context.
*/
export function YearsDropdown(props: YearsDropdownProps): JSX.Element {
const { displayMonth } = props;
const {
fromDate,
toDate,
locale,
styles,
classNames,
components,
formatters: { formatYearCaption },
labels: { labelYearDropdown }
} = useDayPicker();
const years: Date[] = [];
// Dropdown should appear only when both from/toDate is set
if (!fromDate) return <></>;
if (!toDate) return <></>;
const fromYear = fromDate.getFullYear();
const toYear = toDate.getFullYear();
for (let year = fromYear; year <= toYear; year++) {
years.push(setYear(startOfYear(new Date()), year));
}
const handleChange: ChangeEventHandler<HTMLSelectElement> = (e) => {
const newMonth = setYear(
startOfMonth(displayMonth),
Number(e.target.value)
);
props.onChange(newMonth);
};
const DropdownComponent = components?.Dropdown ?? Dropdown;
return (
<DropdownComponent
name="years"
aria-label={labelYearDropdown()}
className={classNames.dropdown_year}
style={styles.dropdown_year}
onChange={handleChange}
value={displayMonth.getFullYear()}
caption={formatYearCaption(displayMonth, { locale })}
>
{years.map((year) => (
<option key={year.getFullYear()} value={year.getFullYear()}>
{formatYearCaption(year, { locale })}
</option>
))}
</DropdownComponent>
);
}

View File

@ -0,0 +1,43 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`when fromDate and toDate are passed in should render the dropdown element 1`] = `
<div
class="rdp-dropdown_year"
>
<span
class="rdp-vhidden"
>
Year:
</span>
<select
aria-label="Year: "
class="rdp-dropdown"
name="years"
>
<option
value="2021"
>
2021
</option>
</select>
<div
aria-hidden="true"
class="rdp-caption_label"
>
2021
<svg
class="rdp-dropdown_icon"
data-testid="iconDropdown"
height="8px"
viewBox="0 0 120 120"
width="8px"
>
<path
d="M4.22182541,48.2218254 C8.44222828,44.0014225 15.2388494,43.9273804 19.5496459,47.9996989 L19.7781746,48.2218254 L60,88.443 L100.221825,48.2218254 C104.442228,44.0014225 111.238849,43.9273804 115.549646,47.9996989 L115.778175,48.2218254 C119.998577,52.4422283 120.07262,59.2388494 116.000301,63.5496459 L115.778175,63.7781746 L67.7781746,111.778175 C63.5577717,115.998577 56.7611506,116.07262 52.4503541,112.000301 L52.2218254,111.778175 L4.22182541,63.7781746 C-0.0739418023,59.4824074 -0.0739418023,52.5175926 4.22182541,48.2218254 Z"
fill="currentColor"
fill-rule="nonzero"
/>
</svg>
</div>
</div>
`;

View File

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

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';

Some files were not shown because too many files have changed in this diff Show More