From 422bef601f9203de8180e63a4ef556906828e2f8 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Mon, 29 Apr 2024 11:42:46 +0300 Subject: [PATCH] feat: add DatePicker --- .../CalendarPage/ui/CalendarPage.module.css | 1 + src/pages/CalendarPage/ui/CalendarPage.ui.tsx | 7 + src/widgets/DatePicker/index.ts | 1 + src/widgets/DatePicker/ui/DatePicker.spec.tsx | 360 ++++++++++++++++++ .../DatePicker/ui/DatePicker.style.module.css | 16 + src/widgets/DatePicker/ui/DatePicker.ui.tsx | 262 +++++++++++++ 6 files changed, 647 insertions(+) create mode 100644 src/widgets/DatePicker/index.ts create mode 100644 src/widgets/DatePicker/ui/DatePicker.spec.tsx create mode 100644 src/widgets/DatePicker/ui/DatePicker.style.module.css create mode 100644 src/widgets/DatePicker/ui/DatePicker.ui.tsx diff --git a/src/pages/CalendarPage/ui/CalendarPage.module.css b/src/pages/CalendarPage/ui/CalendarPage.module.css index c6435d2..1fb3f26 100644 --- a/src/pages/CalendarPage/ui/CalendarPage.module.css +++ b/src/pages/CalendarPage/ui/CalendarPage.module.css @@ -2,4 +2,5 @@ display: flex; justify-content: center; align-items: center; + gap: 50px } diff --git a/src/pages/CalendarPage/ui/CalendarPage.ui.tsx b/src/pages/CalendarPage/ui/CalendarPage.ui.tsx index efc1c36..0aaca35 100644 --- a/src/pages/CalendarPage/ui/CalendarPage.ui.tsx +++ b/src/pages/CalendarPage/ui/CalendarPage.ui.tsx @@ -1,4 +1,5 @@ import { CalendarWidget } from 'widgets/CalendarWidget'; +import { DatePicker } from 'widgets/DatePicker'; import classes from './CalendarPage.module.css'; @@ -10,6 +11,12 @@ const CalendarPage = () => (
+ +
); diff --git a/src/widgets/DatePicker/index.ts b/src/widgets/DatePicker/index.ts new file mode 100644 index 0000000..8d28001 --- /dev/null +++ b/src/widgets/DatePicker/index.ts @@ -0,0 +1 @@ +export { default as DatePicker } from './ui/DatePicker.ui'; diff --git a/src/widgets/DatePicker/ui/DatePicker.spec.tsx b/src/widgets/DatePicker/ui/DatePicker.spec.tsx new file mode 100644 index 0000000..56c189f --- /dev/null +++ b/src/widgets/DatePicker/ui/DatePicker.spec.tsx @@ -0,0 +1,360 @@ +import { render, screen } from '@testing-library/react'; + +import { getDateCellByDataDate, pickCellByDataDate } from 'shared/utils/spec-helpers/date'; + +import DatePicker from './DatePicker.ui'; + +import type { DatePickerProps } from './DatePicker.ui'; + +describe('Test DatePicker', () => { + const setup = (props?: DatePickerProps) => render( + , + ); + + test('Basic render DatePicker', () => { + setup(); + + const datePickerElem = screen.queryByTestId('calendar-widget'); + expect(datePickerElem).toBeVisible(); + }); + + describe('Test base props', () => { + test('Default picked first date', () => { + setup({ + firstDate: new Date(2024, 9, 11), + value: new Date(2024, 9, 15), + }); + + const firstPickedCellElem = screen.queryByTestId('first-selected-cell'); + expect(firstPickedCellElem).toBeVisible(); + expect(firstPickedCellElem).toHaveAttribute('data-date', '10/11/2024'); + + const lastPickedCellElem = screen.queryByTestId('last-selected-cell'); + expect(lastPickedCellElem).not.toBeInTheDocument(); + }); + + test('Default picked last date', () => { + setup({ + lastDate: new Date(2024, 9, 18), + value: new Date(2024, 9, 15), + }); + + const lastPickedCellElem = screen.queryByTestId('last-selected-cell'); + expect(lastPickedCellElem).toBeVisible(); + expect(lastPickedCellElem).toHaveAttribute('data-date', '10/18/2024'); + + const firstPickedCellElem = screen.queryByTestId('first-selected-cell'); + expect(firstPickedCellElem).not.toBeInTheDocument(); + }); + + describe('onSelect handler', () => { + test('No default picked', () => { + const onSelect = jest.fn(); + + const { container } = setup({ + onSelect, + value: new Date(2024, 9, 15), + }); + + pickCellByDataDate(container, '10/18/2024'); + + expect(onSelect).toHaveBeenCalled(); + expect(onSelect).toHaveBeenCalledWith({ + dataDate: '10/18/2024', + date: new Date('10/18/2024'), + type: 'firstDate', + }); + }); + + test('Picked first date', () => { + const onSelect = jest.fn(); + + const { container } = setup({ + onSelect, + value: new Date(2024, 9, 15), + firstDate: new Date(2024, 9, 15), + }); + + pickCellByDataDate(container, '10/18/2024'); + + expect(onSelect).toHaveBeenCalled(); + expect(onSelect).toHaveBeenCalledWith({ + dataDate: '10/18/2024', + date: new Date('10/18/2024'), + type: 'lastDate', + }); + }); + + test('Picked last date', () => { + const onSelect = jest.fn(); + + const { container } = setup({ + onSelect, + value: new Date(2024, 9, 15), + lastDate: new Date(2024, 9, 15), + }); + + pickCellByDataDate(container, '10/11/2024'); + + expect(onSelect).toHaveBeenCalled(); + expect(onSelect).toHaveBeenCalledWith({ + dataDate: '10/11/2024', + date: new Date('10/11/2024'), + type: 'firstDate', + }); + }); + }); + + describe('onUnselect handler', () => { + test('Unpick first date', () => { + const onUnselect = jest.fn(); + + const { container } = setup({ + onUnselect, + value: new Date(2024, 9, 15), + firstDate: new Date(2024, 9, 15), + lastDate: new Date(2024, 9, 18), + }); + + pickCellByDataDate(container, '10/15/2024'); + + expect(onUnselect).toHaveBeenCalled(); + expect(onUnselect).toHaveBeenCalledWith({ + dataDate: '10/15/2024', + date: new Date('10/15/2024'), + type: 'firstDate', + }); + }); + + test('Unpick last date', () => { + const onUnselect = jest.fn(); + + const { container } = setup({ + onUnselect, + value: new Date(2024, 9, 15), + firstDate: new Date(2024, 9, 15), + lastDate: new Date(2024, 9, 18), + }); + + pickCellByDataDate(container, '10/18/2024'); + + expect(onUnselect).toHaveBeenCalled(); + expect(onUnselect).toHaveBeenCalledWith({ + dataDate: '10/18/2024', + date: new Date('10/18/2024'), + type: 'lastDate', + }); + }); + }); + }); + + describe('Test pick date', () => { + test('No picked date, select first', () => { + const { container } = setup({ + value: new Date(2024, 9, 15), + }); + + const cellElem = getDateCellByDataDate(container, '10/11/2024'); + pickCellByDataDate(container, '10/11/2024'); + + const firstSelectedCellElem = screen.queryByTestId('first-selected-cell'); + expect(firstSelectedCellElem).toBe(cellElem); + }); + + test('Picked first date, pick before first date', () => { + const { container } = setup({ + value: new Date(2024, 9, 15), + firstDate: new Date(2024, 9, 15), + }); + + pickCellByDataDate(container, '10/11/2024'); + + const firstPickedCellElem = screen.queryByTestId('first-selected-cell'); + expect(firstPickedCellElem).toHaveAttribute('data-date', '10/11/2024'); + + const lastPickedCellElem = screen.queryByTestId('last-selected-cell'); + expect(lastPickedCellElem).toHaveAttribute('data-date', '10/15/2024'); + }); + + test('Picked first date, pick after first date', () => { + const { container } = setup({ + value: new Date(2024, 9, 15), + firstDate: new Date(2024, 9, 15), + }); + + pickCellByDataDate(container, '10/18/2024'); + + const firstPickedCellElem = screen.queryByTestId('first-selected-cell'); + expect(firstPickedCellElem).toHaveAttribute('data-date', '10/15/2024'); + + const lastPickedCellElem = screen.queryByTestId('last-selected-cell'); + expect(lastPickedCellElem).toHaveAttribute('data-date', '10/18/2024'); + }); + + test('Picked first date, pick this date', () => { + const { container } = setup({ + value: new Date(2024, 9, 15), + firstDate: new Date(2024, 9, 15), + }); + + pickCellByDataDate(container, '10/15/2024'); + + const firstPickedCellElem = screen.queryByTestId('first-selected-cell'); + expect(firstPickedCellElem).not.toBeInTheDocument(); + }); + + test('Picked last date, pick before last date', () => { + const { container } = setup({ + value: new Date(2024, 9, 15), + lastDate: new Date(2024, 9, 15), + }); + + pickCellByDataDate(container, '10/11/2024'); + + const firstPickedCellElem = screen.queryByTestId('first-selected-cell'); + expect(firstPickedCellElem).toHaveAttribute('data-date', '10/11/2024'); + + const lastPickedCellElem = screen.queryByTestId('last-selected-cell'); + expect(lastPickedCellElem).toHaveAttribute('data-date', '10/15/2024'); + }); + + test('Picked last date, pick after last date', () => { + const { container } = setup({ + value: new Date(2024, 9, 15), + lastDate: new Date(2024, 9, 15), + }); + + pickCellByDataDate(container, '10/18/2024'); + + const firstPickedCellElem = screen.queryByTestId('first-selected-cell'); + expect(firstPickedCellElem).toHaveAttribute('data-date', '10/15/2024'); + + const lastPickedCellElem = screen.queryByTestId('last-selected-cell'); + expect(lastPickedCellElem).toHaveAttribute('data-date', '10/18/2024'); + }); + + test('Picked last date, pick this date', () => { + const { container } = setup({ + value: new Date(2024, 9, 15), + lastDate: new Date(2024, 9, 15), + }); + + pickCellByDataDate(container, '10/15/2024'); + + const lastPickedCellElem = screen.queryByTestId('last-selected-cell'); + expect(lastPickedCellElem).not.toBeInTheDocument(); + }); + + test('Picked first & last date, pick date before first', () => { + const { container } = setup({ + value: new Date(2024, 9, 15), + firstDate: new Date(2024, 9, 15), + lastDate: new Date(2024, 9, 18), + }); + + pickCellByDataDate(container, '10/11/2024'); + + const firstPickedCellElem = screen.queryByTestId('first-selected-cell'); + expect(firstPickedCellElem).toHaveAttribute('data-date', '10/11/2024'); + + const lastPickedCellElem = screen.queryByTestId('last-selected-cell'); + expect(lastPickedCellElem).toHaveAttribute('data-date', '10/18/2024'); + }); + + test('Picked first & last date, pick date after last', () => { + const { container } = setup({ + value: new Date(2024, 9, 15), + firstDate: new Date(2024, 9, 15), + lastDate: new Date(2024, 9, 18), + }); + + pickCellByDataDate(container, '10/21/2024'); + + const firstPickedCellElem = screen.queryByTestId('first-selected-cell'); + expect(firstPickedCellElem).toHaveAttribute('data-date', '10/15/2024'); + + const lastPickedCellElem = screen.queryByTestId('last-selected-cell'); + expect(lastPickedCellElem).toHaveAttribute('data-date', '10/21/2024'); + }); + + test('Picked first & last date, pick fist date', () => { + const { container } = setup({ + value: new Date(2024, 9, 15), + firstDate: new Date(2024, 9, 15), + lastDate: new Date(2024, 9, 18), + }); + + pickCellByDataDate(container, '10/15/2024'); + + const firstPickedCellElem = screen.queryByTestId('first-selected-cell'); + expect(firstPickedCellElem).not.toBeInTheDocument(); + + const lastPickedCellElem = screen.queryByTestId('last-selected-cell'); + expect(lastPickedCellElem).toHaveAttribute('data-date', '10/18/2024'); + }); + + test('Picked first & last date, pick last date', () => { + const { container } = setup({ + value: new Date(2024, 9, 15), + firstDate: new Date(2024, 9, 15), + lastDate: new Date(2024, 9, 18), + }); + + pickCellByDataDate(container, '10/18/2024'); + + const firstPickedCellElem = screen.queryByTestId('first-selected-cell'); + expect(firstPickedCellElem).toHaveAttribute('data-date', '10/15/2024'); + + const lastPickedCellElem = screen.queryByTestId('last-selected-cell'); + expect(lastPickedCellElem).not.toBeInTheDocument(); + }); + + test('Picked first & last date, pick the date closer to first date', () => { + const { container } = setup({ + value: new Date(2024, 9, 15), + firstDate: new Date(2024, 9, 15), + lastDate: new Date(2024, 9, 18), + }); + + pickCellByDataDate(container, '10/16/2024'); + + const firstPickedCellElem = screen.queryByTestId('first-selected-cell'); + expect(firstPickedCellElem).toHaveAttribute('data-date', '10/16/2024'); + + const lastPickedCellElem = screen.queryByTestId('last-selected-cell'); + expect(lastPickedCellElem).toHaveAttribute('data-date', '10/18/2024'); + }); + + test('Picked first & last date, pick the date closer to last date', () => { + const { container } = setup({ + value: new Date(2024, 9, 15), + firstDate: new Date(2024, 9, 15), + lastDate: new Date(2024, 9, 18), + }); + + pickCellByDataDate(container, '10/17/2024'); + + const firstPickedCellElem = screen.queryByTestId('first-selected-cell'); + expect(firstPickedCellElem).toHaveAttribute('data-date', '10/15/2024'); + + const lastPickedCellElem = screen.queryByTestId('last-selected-cell'); + expect(lastPickedCellElem).toHaveAttribute('data-date', '10/17/2024'); + }); + + test('Picked first & last date, pick the equidistant date', () => { + const { container } = setup({ + value: new Date(2024, 9, 15), + firstDate: new Date(2024, 9, 15), + lastDate: new Date(2024, 9, 19), + }); + + pickCellByDataDate(container, '10/17/2024'); + + const firstPickedCellElem = screen.queryByTestId('first-selected-cell'); + expect(firstPickedCellElem).toHaveAttribute('data-date', '10/17/2024'); + + const lastPickedCellElem = screen.queryByTestId('last-selected-cell'); + expect(lastPickedCellElem).toHaveAttribute('data-date', '10/19/2024'); + }); + }); +}); diff --git a/src/widgets/DatePicker/ui/DatePicker.style.module.css b/src/widgets/DatePicker/ui/DatePicker.style.module.css new file mode 100644 index 0000000..03fcd49 --- /dev/null +++ b/src/widgets/DatePicker/ui/DatePicker.style.module.css @@ -0,0 +1,16 @@ +.firstDate { + background-color: blue; + color: white; + border-top-left-radius: 10px +} + +.inSelection { + background: gold; + color: darkgrey; +} + +.lastDate { + background-color: blue; + color: white; + border-bottom-right-radius: 10px +} diff --git a/src/widgets/DatePicker/ui/DatePicker.ui.tsx b/src/widgets/DatePicker/ui/DatePicker.ui.tsx new file mode 100644 index 0000000..2436918 --- /dev/null +++ b/src/widgets/DatePicker/ui/DatePicker.ui.tsx @@ -0,0 +1,262 @@ +import classnames from 'classnames'; +import { useState } from 'react'; + +import type { DataDateString } from 'shared/types/date'; +import { daysCountBetween, getDataDateString } from 'shared/utils/date'; +import { exhaustiveCheck } from 'shared/utils/type'; + +import { CalendarWidget } from '../../CalendarWidget'; + +import classes from './DatePicker.style.module.css'; + +import type { CalendarWidgetProps } from '../../CalendarWidget'; +import type { FC } from 'react'; + +export type DatePickerProps = CalendarWidgetProps & { + firstDate?: Date; + lastDate?: Date; + onSelect?: (params: { dataDate: DataDateString; date: Date; type: 'firstDate' | 'lastDate' }) => void; + onUnselect?: (params: { dataDate: DataDateString; date: Date; type: 'firstDate' | 'lastDate' }) => void; +}; + +const DatePicker: FC = (props) => { + const { + firstDate, + lastDate, + onSelect, + onUnselect, + ...restWidgetProps + } = props; + + const [firstSelectedDate, setFirstSelectedDate] = useState( + firstDate + ? getDataDateString(firstDate) + : null, + ); + const [lastSelectedDate, setLastSelectedDate] = useState( + lastDate + ? getDataDateString(lastDate) + : null, + ); + + const isSelectedFirstDate = firstSelectedDate !== null; + const isSelectedLastDate = lastSelectedDate !== null; + const isSelectedBothDate = isSelectedFirstDate && isSelectedLastDate; + + const isCellInSelection = (dataDate: DataDateString) => { + if (!isSelectedFirstDate || !isSelectedLastDate) { + return false; + } + + const dateTime = new Date(dataDate).getTime(); + const firstDateTime = new Date(firstSelectedDate).getTime(); + const lastDateTime = new Date(lastSelectedDate).getTime(); + + return dateTime >= firstDateTime && dateTime <= lastDateTime; + }; + + const toggleCellDate = (params: { dataDate: DataDateString | null; type: 'firstDate' | 'lastDate' }) => { + const { dataDate, type } = params; + + if (dataDate === null) { + let typedDataDate: DataDateString; + + switch (type) { + case 'firstDate': + if (!isSelectedFirstDate) { + return; + } + typedDataDate = firstSelectedDate; + setFirstSelectedDate(null); + break; + + case 'lastDate': + if (!isSelectedLastDate) { + return; + } + typedDataDate = lastSelectedDate; + setLastSelectedDate(null); + + break; + default: + exhaustiveCheck(type); + break; + } + + onUnselect?.({ + type, + // @ts-ignore + dataDate: typedDataDate, + // @ts-ignore + date: new Date(typedDataDate), + }); + + return; + } + + switch (type) { + case 'firstDate': + setFirstSelectedDate(dataDate); + break; + + case 'lastDate': + setLastSelectedDate(dataDate); + break; + + default: + exhaustiveCheck(type); + break; + } + onSelect?.({ + type, + dataDate, + date: new Date(dataDate), + }); + }; + + const toggleCellSelection = (dataDate: DataDateString) => { + if (!isSelectedFirstDate && !isSelectedLastDate) { + toggleCellDate({ dataDate, type: 'firstDate' }); + + return; + } + + if (isSelectedFirstDate && !isSelectedLastDate) { + if (dataDate < firstSelectedDate) { + toggleCellDate({ dataDate: firstSelectedDate, type: 'lastDate' }); + toggleCellDate({ dataDate, type: 'firstDate' }); + + return; + } + + if (dataDate > firstSelectedDate) { + toggleCellDate({ dataDate, type: 'lastDate' }); + + return; + } + + toggleCellDate({ dataDate: null, type: 'firstDate' }); + } + + if (!isSelectedFirstDate && isSelectedLastDate) { + if (dataDate < lastSelectedDate) { + toggleCellDate({ dataDate, type: 'firstDate' }); + + return; + } + + if (dataDate > lastSelectedDate) { + toggleCellDate({ dataDate: lastSelectedDate, type: 'firstDate' }); + toggleCellDate({ dataDate, type: 'lastDate' }); + + return; + } + + toggleCellDate({ dataDate: null, type: 'lastDate' }); + } + + if (isSelectedBothDate) { + if (dataDate < firstSelectedDate) { + toggleCellDate({ dataDate, type: 'firstDate' }); + + return; + } + + if (dataDate > lastSelectedDate) { + toggleCellDate({ dataDate, type: 'lastDate' }); + + return; + } + + if (dataDate === firstSelectedDate) { + toggleCellDate({ dataDate: null, type: 'firstDate' }); + + return; + } + + if (dataDate === lastSelectedDate) { + toggleCellDate({ dataDate: null, type: 'lastDate' }); + + return; + } + + const daysToFirstDate = daysCountBetween(new Date(firstSelectedDate), new Date(dataDate)); + const daysToLastDate = daysCountBetween(new Date(lastSelectedDate), new Date(dataDate)); + + if (daysToFirstDate < daysToLastDate) { + toggleCellDate({ dataDate, type: 'firstDate' }); + + return; + } + + if (daysToFirstDate > daysToLastDate) { + toggleCellDate({ dataDate, type: 'lastDate' }); + + return; + } + + toggleCellDate({ dataDate, type: 'firstDate' }); + } + }; + + const isFirstDate = (dataDate: DataDateString) => dataDate === firstSelectedDate; + + const isLastDate = (dataDate: DataDateString) => dataDate === lastSelectedDate; + + const getDataTestId = (dataDate: DataDateString) => { + if (isFirstDate(dataDate)) { + return { + 'data-testid': 'first-selected-cell', + }; + } + + if (isLastDate(dataDate)) { + return { + 'data-testid': 'last-selected-cell', + }; + } + + if (isCellInSelection(dataDate)) { + return { + 'data-testid': 'cell-in-range', + }; + } + + return {}; + }; + + return ( +
+ + + ({ + onClick: () => { + toggleCellSelection(dataDate); + }, + className: classnames({ + [classes.firstDate as string]: isFirstDate(dataDate), + [classes.inSelection as string]: isCellInSelection(dataDate) + && !isFirstDate(dataDate) + && !isLastDate(dataDate), + [classes.lastDate as string]: isLastDate(dataDate), + }), + ...getDataTestId(dataDate), + })} + {...restWidgetProps} + /> +
+ + ); +}; + +export default DatePicker;