From 44258d4394c16719f3ca162cbfac309cd2719907 Mon Sep 17 00:00:00 2001 From: Mikhail Lukin Date: Wed, 20 Nov 2024 15:54:19 +0700 Subject: [PATCH 1/7] feat(calendar): add changeable button content --- packages/calendar/src/Component.test.tsx | 91 ++++++++++++++++++- .../components/calendar-mobile/Component.tsx | 11 ++- .../src/components/calendar-mobile/typings.ts | 18 ++++ 3 files changed, 115 insertions(+), 5 deletions(-) diff --git a/packages/calendar/src/Component.test.tsx b/packages/calendar/src/Component.test.tsx index f4329a3380..308ef731bf 100644 --- a/packages/calendar/src/Component.test.tsx +++ b/packages/calendar/src/Component.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { render, fireEvent, renderHook } from '@testing-library/react'; +import { render, fireEvent, renderHook, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import subDays from 'date-fns/subDays'; import addDays from 'date-fns/addDays'; @@ -1436,3 +1436,92 @@ describe('hook tests', () => { expect(result.current.selectedTo).toBe(initialDate); }); }); + +describe('CalendarMobile buttons content', () => { + it('should pass default range select button text', () => { + render( + , + ); + + const text = screen.getByText('Выбрать'); + + expect(text).toBeInTheDocument(); + }); + + it('should pass default range reset button text', () => { + render( + , + ); + + const text = screen.getByText('Сбросить'); + + expect(text).toBeInTheDocument(); + }); + it('should pass range select button text', () => { + render( + , + ); + + const text = screen.getByText('selectButtonContent'); + + expect(text).toBeInTheDocument(); + }); + + it('should pass range reset button text', () => { + render( + , + ); + + const text = screen.getByText('resetButtonContent'); + + expect(text).toBeInTheDocument(); + }); + + it('should pass default select button text', () => { + render(); + + const text = screen.getByText('Выбрать'); + + expect(text).toBeInTheDocument(); + }); + + it('should pass select button text', () => { + render( + , + ); + + const text = screen.getByText('selectButtonContent'); + + expect(text).toBeInTheDocument(); + }); + + it('should pass default cancel button text', () => { + render(); + + const text = screen.getByText('Отмена'); + + expect(text).toBeInTheDocument(); + }); + + it('should pass cancel button text', () => { + render(); + + const text = screen.getByText('cancelButtonContent'); + + expect(text).toBeInTheDocument(); + }); +}); diff --git a/packages/calendar/src/components/calendar-mobile/Component.tsx b/packages/calendar/src/components/calendar-mobile/Component.tsx index 744f4235b9..0ba9b09afd 100644 --- a/packages/calendar/src/components/calendar-mobile/Component.tsx +++ b/packages/calendar/src/components/calendar-mobile/Component.tsx @@ -333,6 +333,9 @@ export const CalendarMobile = forwardRef( yearsAmount = 3, onApply, clickableMonth, + cancelButtonContent = 'Отмена', + selectButtonContent = 'Выбрать', + resetButtonContent = 'Сбросить', ...restProps }, ref, @@ -411,7 +414,7 @@ export const CalendarMobile = forwardRef( onClick={handleClear} dataTestId={getDataTestId(dataTestId, 'btn-reset')} > - Сбросить + {resetButtonContent} ( disabled={selectButtonDisabled} dataTestId={getDataTestId(dataTestId, 'btn-apply')} > - Выбрать + {selectButtonContent} ); @@ -440,7 +443,7 @@ export const CalendarMobile = forwardRef( onClick={handleApply} dataTestId={getDataTestId(dataTestId, 'btn-apply')} > - Выбрать + {selectButtonContent} ); } @@ -453,7 +456,7 @@ export const CalendarMobile = forwardRef( onClick={handleClose} dataTestId={getDataTestId(dataTestId, 'btn-reset')} > - Отмена + {cancelButtonContent} ); }; diff --git a/packages/calendar/src/components/calendar-mobile/typings.ts b/packages/calendar/src/components/calendar-mobile/typings.ts index f4c4d3d324..9e4629cc44 100644 --- a/packages/calendar/src/components/calendar-mobile/typings.ts +++ b/packages/calendar/src/components/calendar-mobile/typings.ts @@ -68,5 +68,23 @@ export type CalendarMobileProps = { * При клике на месяц будут выбраны все доступные дни месяца */ clickableMonth?: boolean; + + /** + * Контент кнопки "Отмена" + * @default Отмена + */ + cancelButtonContent?: string; + + /** + * Контент кнопки "Выбрать" + * @default Выбрать + */ + selectButtonContent?: string; + + /** + * Контент кнопки "Сбросить" + * @default Сбросить + */ + resetButtonContent?: string; } & CalendarContentProps & Pick; From 3fe44d771b58add7b1e0b40b627e9031816b8ebc Mon Sep 17 00:00:00 2001 From: Mikhail Lukin Date: Wed, 20 Nov 2024 17:56:18 +0700 Subject: [PATCH 2/7] feat(calendar): separate calendarMonthOnlyView --- .../components/calendar-mobile/Component.tsx | 285 +----------------- .../calendarMonthOnlyView.tsx | 284 +++++++++++++++++ .../calendar-month-only-view/index.ts | 1 + .../src/components/calendar-mobile/index.ts | 1 + 4 files changed, 290 insertions(+), 281 deletions(-) create mode 100644 packages/calendar/src/components/calendar-mobile/components/calendar-month-only-view/calendarMonthOnlyView.tsx create mode 100644 packages/calendar/src/components/calendar-mobile/components/calendar-month-only-view/index.ts diff --git a/packages/calendar/src/components/calendar-mobile/Component.tsx b/packages/calendar/src/components/calendar-mobile/Component.tsx index 0ba9b09afd..046ba50c54 100644 --- a/packages/calendar/src/components/calendar-mobile/Component.tsx +++ b/packages/calendar/src/components/calendar-mobile/Component.tsx @@ -1,39 +1,17 @@ -import React, { forwardRef, useMemo, useRef, useState } from 'react'; +import React, { forwardRef, useState } from 'react'; import mergeRefs from 'react-merge-refs'; -import { Virtuoso } from 'react-virtuoso'; import { ResizeObserver as ResizeObserverPolyfill } from '@juggle/resize-observer'; import cn from 'classnames'; -import endOfDay from 'date-fns/endOfDay'; -import isAfter from 'date-fns/isAfter'; -import isSameDay from 'date-fns/isSameDay'; -import isSameMonth from 'date-fns/isSameMonth'; -import isThisMonth from 'date-fns/isThisMonth'; -import lastDayOfMonth from 'date-fns/lastDayOfMonth'; -import startOfDay from 'date-fns/startOfDay'; -import startOfMonth from 'date-fns/startOfMonth'; import { ButtonMobile } from '@alfalab/core-components-button/mobile'; import { ModalMobile } from '@alfalab/core-components-modal/mobile'; import { getDataTestId } from '@alfalab/core-components-shared'; -import { Typography } from '@alfalab/core-components-typography'; import { CalendarDesktop } from '../../desktop'; -import { Month } from '../../typings'; -import { useCalendar } from '../../useCalendar'; -import { useRange } from '../../useRange'; -import { - addonArrayToHashTable, - dateArrayToHashTable, - generateMonths, - generateWeeks, - isRangeValue, - limitDate, - monthName, - WEEKDAYS, -} from '../../utils'; -import { DaysTable } from '../days-table'; +import { isRangeValue, WEEKDAYS } from '../../utils'; -import { CalendarContentProps, CalendarMobileProps } from './typings'; +import { CalendarMonthOnlyView } from './components/calendar-month-only-view'; +import { CalendarMobileProps } from './typings'; import backdropTransitionStyles from './backdrop-transitions.module.css'; import styles from './index.module.css'; @@ -44,261 +22,6 @@ if (typeof window !== 'undefined' && !window.ResizeObserver) { window.ResizeObserver = ResizeObserverPolyfill; } -export const CalendarMonthOnlyView = ({ - value, - mode = 'single', - rangeBehavior = 'clarification', - month: monthTimestamp, - minDate: minDateTimestamp, - maxDate: maxDateTimestamp, - defaultMonth: defaultMonthTimestamp, - offDays, - events, - holidays, - onChange, - onMonthTitleClick, - selectedFrom, - selectedTo, - yearsAmount = 3, - dayAddons, - shape = 'rounded', - scrollableContainer, - clickableMonth, -}: CalendarContentProps & { - /** - * FIXME нужно сделать для компонента CalendarMonthOnlyView отдельный тип пропсов, т.к. тип CalendarContentProps intersection для типа CalendarMobileProps - * FIXME это приводит к тому, что в доку сторибука попадают типы пропсов, которые нужны для работы компонента CalendarMonthOnlyView, но не нужны для компонента CalendarMobile - * TODO Вынести компонент CalendarMonthOnlyView в отдельный файл - */ - clickableMonth?: boolean; -}) => { - const range = useRange({ - mode, - value, - selectedFrom, - selectedTo, - rangeBehavior, - onChange, - }); - - const month = useMemo( - () => (monthTimestamp ? new Date(monthTimestamp) : undefined), - [monthTimestamp], - ); - - const minDate = useMemo( - () => (minDateTimestamp ? startOfDay(minDateTimestamp) : undefined), - [minDateTimestamp], - ); - - const maxDate = useMemo(() => { - // блокируем последующие дни после текущего - if (clickableMonth && !maxDateTimestamp) { - return new Date(); - } - - return maxDateTimestamp ? endOfDay(maxDateTimestamp) : undefined; - }, [maxDateTimestamp, clickableMonth]); - - const selected = useMemo( - () => (range.value ? new Date(range.value) : undefined), - [range.value], - ); - - const startingDate = useRef(range.value); - - const defaultMonth = useMemo( - () => - startOfMonth( - selected || - limitDate( - defaultMonthTimestamp || Date.now(), - minDateTimestamp, - maxDateTimestamp, - ), - ), - [defaultMonthTimestamp, maxDateTimestamp, minDateTimestamp, selected], - ); - - const { activeMonth, highlighted, getDayProps } = useCalendar({ - month, - defaultMonth, - view: 'months', - minDate, - maxDate, - selected, - offDays, - events, - onChange: range.onChange, - dayAddons, - }); - - const activeMonths = useMemo(() => { - const eventsMap = dateArrayToHashTable(events || []); - const offDaysMap = dateArrayToHashTable(offDays || []); - const holidaysMap = dateArrayToHashTable(holidays || []); - const dayAddonsMap = addonArrayToHashTable(dayAddons || []); - - const prevMonths: Month[] = []; - const nextMonths: Month[] = []; - - const date = startingDate.current ? new Date(startingDate.current) : new Date(); - const currentYear = date.getFullYear(); - const currYearMonths = generateMonths(date, {}); - - for (let i = 0; i < yearsAmount; i++) { - const prevYear = date.setFullYear(currentYear - (i + 1)); - const nextYear = date.setFullYear(currentYear + (i + 1)); - - const prevYearMonths = generateMonths(new Date(prevYear), {}); - const nextYearMonths = generateMonths(new Date(nextYear), {}); - - prevMonths.unshift(...prevYearMonths); - nextMonths.push(...nextYearMonths); - } - - const generatedMonths = [...prevMonths, ...currYearMonths, ...nextMonths]; - - return generatedMonths.map((item) => ({ - ...item, - weeks: generateWeeks(item.date, { - minDate, - maxDate, - selected, - eventsMap, - offDaysMap, - holidaysMap, - dayAddonsMap, - }), - title: `${monthName(item.date)} ${item.date.getFullYear()}`, - })); - }, [events, offDays, holidays, dayAddons, minDate, maxDate, yearsAmount, selected]); - - const initialMonthIndex = useMemo(() => { - const date = range.value || range.selectedFrom || activeMonth.getTime() || Date.now(); - - return activeMonths.findIndex((m) => isSameMonth(date, m.date)); - }, [range.value, range.selectedFrom, activeMonth, activeMonths]); - - // заголовок должен становиться активным если выбран весь доступный период в месяце - const isMonthActive = (currentMonthIndex: number) => { - if (value && isRangeValue(value)) { - const { date: initialMonthDate } = activeMonths[initialMonthIndex]; - const { date: currentMonthDate } = activeMonths[currentMonthIndex]; - - const firstAvailableDayOfMonth = startOfMonth(initialMonthDate).getTime(); - /** - * последний доступный день месяца в timestamp - * представлен в виде последнего календарного дня, либо в виде текущей даты для актуального месяца - */ - const lastAvailableDayOfMonth = isThisMonth(initialMonthDate) - ? startOfDay(new Date()).getTime() - : lastDayOfMonth(initialMonthDate).getTime(); - const { dateFrom, dateTo } = value; - - if ( - dateFrom && - dateTo && - isSameMonth(initialMonthDate, currentMonthDate) && - isSameDay(firstAvailableDayOfMonth, dateFrom) && - isSameDay(lastAvailableDayOfMonth, dateTo) - ) { - return true; - } - } - - return false; - }; - - const handleClickMonthLabel = (index: number) => { - if (onChange) { - const { date } = activeMonths[index]; - const firstAvailableDayOfMonth = startOfMonth(date).getTime(); - const lastAvailableDayOfMonth = isThisMonth(date) - ? startOfDay(new Date()).getTime() - : lastDayOfMonth(date).getTime(); - - if (isMonthActive(index)) { - onChange(); - } else { - onChange(firstAvailableDayOfMonth, lastAvailableDayOfMonth); - } - } - }; - - const getMonthLabel = (index: number, isClickableMonth?: boolean) => { - if (isClickableMonth) { - return ( - - {activeMonths[index].title} - - ); - } - - return `\u00A0${activeMonths[index].title}\u00A0`; - }; - - const renderMonth = (index: number) => { - const isAfterDate = isAfter(activeMonths[index].date, activeMonth); - - return ( -
- {onMonthTitleClick ? ( - /* eslint-disable-next-line jsx-a11y/click-events-have-key-events */ - - {activeMonths[index].title} - - ) : ( - // eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions - handleClickMonthLabel(index) })} - > - {getMonthLabel(index, clickableMonth)} - - )} - -
- ); - }; - - return ( - el.getBoundingClientRect().height + 32} - customScrollParent={scrollableContainer} - useWindowScroll={true} - className={styles.virtuoso} - /> - ); -}; - export const CalendarMonthOnlyViewHeader = () => ( diff --git a/packages/calendar/src/components/calendar-mobile/components/calendar-month-only-view/calendarMonthOnlyView.tsx b/packages/calendar/src/components/calendar-mobile/components/calendar-month-only-view/calendarMonthOnlyView.tsx new file mode 100644 index 0000000000..87d7401139 --- /dev/null +++ b/packages/calendar/src/components/calendar-mobile/components/calendar-month-only-view/calendarMonthOnlyView.tsx @@ -0,0 +1,284 @@ +import React, { useMemo, useRef } from 'react'; +import { Virtuoso } from 'react-virtuoso'; +import cn from 'classnames'; +import endOfDay from 'date-fns/endOfDay'; +import isAfter from 'date-fns/isAfter'; +import isSameDay from 'date-fns/isSameDay'; +import isSameMonth from 'date-fns/isSameMonth'; +import isThisMonth from 'date-fns/isThisMonth'; +import lastDayOfMonth from 'date-fns/lastDayOfMonth'; +import startOfDay from 'date-fns/startOfDay'; +import startOfMonth from 'date-fns/startOfMonth'; + +import { Typography } from '@alfalab/core-components-typography'; + +import { Month } from '../../../../typings'; +import { useCalendar } from '../../../../useCalendar'; +import { useRange } from '../../../../useRange'; +import { + addonArrayToHashTable, + dateArrayToHashTable, + generateMonths, + generateWeeks, + isRangeValue, + limitDate, + monthName, +} from '../../../../utils'; +import { DaysTable } from '../../../days-table'; +import { CalendarContentProps } from '../../typings'; + +import styles from '../../index.module.css'; + +export const CalendarMonthOnlyView = ({ + value, + mode = 'single', + rangeBehavior = 'clarification', + month: monthTimestamp, + minDate: minDateTimestamp, + maxDate: maxDateTimestamp, + defaultMonth: defaultMonthTimestamp, + offDays, + events, + holidays, + onChange, + onMonthTitleClick, + selectedFrom, + selectedTo, + yearsAmount = 3, + dayAddons, + shape = 'rounded', + scrollableContainer, + clickableMonth, +}: CalendarContentProps & { + /** + * FIXME нужно сделать для компонента CalendarMonthOnlyView отдельный тип пропсов, т.к. тип CalendarContentProps intersection для типа CalendarMobileProps + * FIXME это приводит к тому, что в доку сторибука попадают типы пропсов, которые нужны для работы компонента CalendarMonthOnlyView, но не нужны для компонента CalendarMobile + */ + clickableMonth?: boolean; +}) => { + const range = useRange({ + mode, + value, + selectedFrom, + selectedTo, + rangeBehavior, + onChange, + }); + + const month = useMemo( + () => (monthTimestamp ? new Date(monthTimestamp) : undefined), + [monthTimestamp], + ); + + const minDate = useMemo( + () => (minDateTimestamp ? startOfDay(minDateTimestamp) : undefined), + [minDateTimestamp], + ); + + const maxDate = useMemo(() => { + // блокируем последующие дни после текущего + if (clickableMonth && !maxDateTimestamp) { + return new Date(); + } + + return maxDateTimestamp ? endOfDay(maxDateTimestamp) : undefined; + }, [maxDateTimestamp, clickableMonth]); + + const selected = useMemo( + () => (range.value ? new Date(range.value) : undefined), + [range.value], + ); + + const startingDate = useRef(range.value); + + const defaultMonth = useMemo( + () => + startOfMonth( + selected || + limitDate( + defaultMonthTimestamp || Date.now(), + minDateTimestamp, + maxDateTimestamp, + ), + ), + [defaultMonthTimestamp, maxDateTimestamp, minDateTimestamp, selected], + ); + + const { activeMonth, highlighted, getDayProps } = useCalendar({ + month, + defaultMonth, + view: 'months', + minDate, + maxDate, + selected, + offDays, + events, + onChange: range.onChange, + dayAddons, + }); + + const activeMonths = useMemo(() => { + const eventsMap = dateArrayToHashTable(events || []); + const offDaysMap = dateArrayToHashTable(offDays || []); + const holidaysMap = dateArrayToHashTable(holidays || []); + const dayAddonsMap = addonArrayToHashTable(dayAddons || []); + + const prevMonths: Month[] = []; + const nextMonths: Month[] = []; + + const date = startingDate.current ? new Date(startingDate.current) : new Date(); + const currentYear = date.getFullYear(); + const currYearMonths = generateMonths(date, {}); + + for (let i = 0; i < yearsAmount; i++) { + const prevYear = date.setFullYear(currentYear - (i + 1)); + const nextYear = date.setFullYear(currentYear + (i + 1)); + + const prevYearMonths = generateMonths(new Date(prevYear), {}); + const nextYearMonths = generateMonths(new Date(nextYear), {}); + + prevMonths.unshift(...prevYearMonths); + nextMonths.push(...nextYearMonths); + } + + const generatedMonths = [...prevMonths, ...currYearMonths, ...nextMonths]; + + return generatedMonths.map((item) => ({ + ...item, + weeks: generateWeeks(item.date, { + minDate, + maxDate, + selected, + eventsMap, + offDaysMap, + holidaysMap, + dayAddonsMap, + }), + title: `${monthName(item.date)} ${item.date.getFullYear()}`, + })); + }, [events, offDays, holidays, dayAddons, minDate, maxDate, yearsAmount, selected]); + + const initialMonthIndex = useMemo(() => { + const date = range.value || range.selectedFrom || activeMonth.getTime() || Date.now(); + + return activeMonths.findIndex((m) => isSameMonth(date, m.date)); + }, [range.value, range.selectedFrom, activeMonth, activeMonths]); + + // заголовок должен становиться активным если выбран весь доступный период в месяце + const isMonthActive = (currentMonthIndex: number) => { + if (value && isRangeValue(value)) { + const { date: initialMonthDate } = activeMonths[initialMonthIndex]; + const { date: currentMonthDate } = activeMonths[currentMonthIndex]; + + const firstAvailableDayOfMonth = startOfMonth(initialMonthDate).getTime(); + /** + * последний доступный день месяца в timestamp + * представлен в виде последнего календарного дня, либо в виде текущей даты для актуального месяца + */ + const lastAvailableDayOfMonth = isThisMonth(initialMonthDate) + ? startOfDay(new Date()).getTime() + : lastDayOfMonth(initialMonthDate).getTime(); + const { dateFrom, dateTo } = value; + + if ( + dateFrom && + dateTo && + isSameMonth(initialMonthDate, currentMonthDate) && + isSameDay(firstAvailableDayOfMonth, dateFrom) && + isSameDay(lastAvailableDayOfMonth, dateTo) + ) { + return true; + } + } + + return false; + }; + + const handleClickMonthLabel = (index: number) => { + if (onChange) { + const { date } = activeMonths[index]; + const firstAvailableDayOfMonth = startOfMonth(date).getTime(); + const lastAvailableDayOfMonth = isThisMonth(date) + ? startOfDay(new Date()).getTime() + : lastDayOfMonth(date).getTime(); + + if (isMonthActive(index)) { + onChange(); + } else { + onChange(firstAvailableDayOfMonth, lastAvailableDayOfMonth); + } + } + }; + + const getMonthLabel = (index: number, isClickableMonth?: boolean) => { + if (isClickableMonth) { + return ( + + {activeMonths[index].title} + + ); + } + + return `\u00A0${activeMonths[index].title}\u00A0`; + }; + + const renderMonth = (index: number) => { + const isAfterDate = isAfter(activeMonths[index].date, activeMonth); + + return ( +
+ {onMonthTitleClick ? ( + /* eslint-disable-next-line jsx-a11y/click-events-have-key-events */ + + {activeMonths[index].title} + + ) : ( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions + handleClickMonthLabel(index) })} + > + {getMonthLabel(index, clickableMonth)} + + )} + +
+ ); + }; + + return ( + el.getBoundingClientRect().height + 32} + customScrollParent={scrollableContainer} + useWindowScroll={true} + className={styles.virtuoso} + /> + ); +}; diff --git a/packages/calendar/src/components/calendar-mobile/components/calendar-month-only-view/index.ts b/packages/calendar/src/components/calendar-mobile/components/calendar-month-only-view/index.ts new file mode 100644 index 0000000000..2133cd05a3 --- /dev/null +++ b/packages/calendar/src/components/calendar-mobile/components/calendar-month-only-view/index.ts @@ -0,0 +1 @@ +export { CalendarMonthOnlyView } from './calendarMonthOnlyView'; diff --git a/packages/calendar/src/components/calendar-mobile/index.ts b/packages/calendar/src/components/calendar-mobile/index.ts index fc34df14b2..38a77257e5 100644 --- a/packages/calendar/src/components/calendar-mobile/index.ts +++ b/packages/calendar/src/components/calendar-mobile/index.ts @@ -1,2 +1,3 @@ export * from './Component'; export * from './typings'; +export * from './components/calendar-month-only-view'; From 7aad4a509ccbfb672304921b2ccd4f5be7fc729d Mon Sep 17 00:00:00 2001 From: Mikhail Lukin Date: Wed, 20 Nov 2024 18:08:58 +0700 Subject: [PATCH 3/7] feat(calendar): fix types --- .../calendar-month-only-view/calendarMonthOnlyView.tsx | 7 +++---- .../calendar/src/components/calendar-mobile/typings.ts | 5 ----- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/calendar/src/components/calendar-mobile/components/calendar-month-only-view/calendarMonthOnlyView.tsx b/packages/calendar/src/components/calendar-mobile/components/calendar-month-only-view/calendarMonthOnlyView.tsx index 87d7401139..ef5967ce44 100644 --- a/packages/calendar/src/components/calendar-mobile/components/calendar-month-only-view/calendarMonthOnlyView.tsx +++ b/packages/calendar/src/components/calendar-mobile/components/calendar-month-only-view/calendarMonthOnlyView.tsx @@ -50,10 +50,9 @@ export const CalendarMonthOnlyView = ({ scrollableContainer, clickableMonth, }: CalendarContentProps & { - /** - * FIXME нужно сделать для компонента CalendarMonthOnlyView отдельный тип пропсов, т.к. тип CalendarContentProps intersection для типа CalendarMobileProps - * FIXME это приводит к тому, что в доку сторибука попадают типы пропсов, которые нужны для работы компонента CalendarMonthOnlyView, но не нужны для компонента CalendarMobile - */ + /** Родительский контейнер для отслеживания скролла */ + scrollableContainer?: HTMLElement; + clickableMonth?: boolean; }) => { const range = useRange({ diff --git a/packages/calendar/src/components/calendar-mobile/typings.ts b/packages/calendar/src/components/calendar-mobile/typings.ts index 9e4629cc44..96d7098d7a 100644 --- a/packages/calendar/src/components/calendar-mobile/typings.ts +++ b/packages/calendar/src/components/calendar-mobile/typings.ts @@ -26,11 +26,6 @@ export type CalendarContentProps = { * Количество лет для генерации в обе стороны от текущего года */ yearsAmount?: number; - - /** - * Родительский контейнер для отслеживания скролла - */ - scrollableContainer?: HTMLElement; } & Omit; export type CalendarMobileProps = { From 2d1415217061db9678abe2382ffea9535291820b Mon Sep 17 00:00:00 2001 From: Mikhail Lukin Date: Wed, 20 Nov 2024 18:11:37 +0700 Subject: [PATCH 4/7] feat(calendar): add changeset --- .changeset/loud-months-smash.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/loud-months-smash.md diff --git a/.changeset/loud-months-smash.md b/.changeset/loud-months-smash.md new file mode 100644 index 0000000000..363644f804 --- /dev/null +++ b/.changeset/loud-months-smash.md @@ -0,0 +1,5 @@ +--- +'@alfalab/core-components-calendar': minor +--- + +Добавлены пропсы для передачи кастомного текста в кнопки мобильного календаря From f85aa9cd1a4c314d6d059668080c6664aa0d40e2 Mon Sep 17 00:00:00 2001 From: Mikhail Lukin Date: Tue, 10 Dec 2024 14:03:00 +0700 Subject: [PATCH 5/7] Revert "feat(calendar): fix types" This reverts commit 7aad4a509ccbfb672304921b2ccd4f5be7fc729d. --- .../calendar-month-only-view/calendarMonthOnlyView.tsx | 7 ++++--- .../calendar/src/components/calendar-mobile/typings.ts | 5 +++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/calendar/src/components/calendar-mobile/components/calendar-month-only-view/calendarMonthOnlyView.tsx b/packages/calendar/src/components/calendar-mobile/components/calendar-month-only-view/calendarMonthOnlyView.tsx index ef5967ce44..87d7401139 100644 --- a/packages/calendar/src/components/calendar-mobile/components/calendar-month-only-view/calendarMonthOnlyView.tsx +++ b/packages/calendar/src/components/calendar-mobile/components/calendar-month-only-view/calendarMonthOnlyView.tsx @@ -50,9 +50,10 @@ export const CalendarMonthOnlyView = ({ scrollableContainer, clickableMonth, }: CalendarContentProps & { - /** Родительский контейнер для отслеживания скролла */ - scrollableContainer?: HTMLElement; - + /** + * FIXME нужно сделать для компонента CalendarMonthOnlyView отдельный тип пропсов, т.к. тип CalendarContentProps intersection для типа CalendarMobileProps + * FIXME это приводит к тому, что в доку сторибука попадают типы пропсов, которые нужны для работы компонента CalendarMonthOnlyView, но не нужны для компонента CalendarMobile + */ clickableMonth?: boolean; }) => { const range = useRange({ diff --git a/packages/calendar/src/components/calendar-mobile/typings.ts b/packages/calendar/src/components/calendar-mobile/typings.ts index 96d7098d7a..9e4629cc44 100644 --- a/packages/calendar/src/components/calendar-mobile/typings.ts +++ b/packages/calendar/src/components/calendar-mobile/typings.ts @@ -26,6 +26,11 @@ export type CalendarContentProps = { * Количество лет для генерации в обе стороны от текущего года */ yearsAmount?: number; + + /** + * Родительский контейнер для отслеживания скролла + */ + scrollableContainer?: HTMLElement; } & Omit; export type CalendarMobileProps = { From babb7f55efac82c26240d16b979438f52d83ad52 Mon Sep 17 00:00:00 2001 From: Mikhail Lukin Date: Tue, 10 Dec 2024 14:03:00 +0700 Subject: [PATCH 6/7] Revert "feat(calendar): separate calendarMonthOnlyView" This reverts commit 3fe44d771b58add7b1e0b40b627e9031816b8ebc. --- .../components/calendar-mobile/Component.tsx | 285 +++++++++++++++++- .../calendarMonthOnlyView.tsx | 284 ----------------- .../calendar-month-only-view/index.ts | 1 - .../src/components/calendar-mobile/index.ts | 1 - 4 files changed, 281 insertions(+), 290 deletions(-) delete mode 100644 packages/calendar/src/components/calendar-mobile/components/calendar-month-only-view/calendarMonthOnlyView.tsx delete mode 100644 packages/calendar/src/components/calendar-mobile/components/calendar-month-only-view/index.ts diff --git a/packages/calendar/src/components/calendar-mobile/Component.tsx b/packages/calendar/src/components/calendar-mobile/Component.tsx index 046ba50c54..0ba9b09afd 100644 --- a/packages/calendar/src/components/calendar-mobile/Component.tsx +++ b/packages/calendar/src/components/calendar-mobile/Component.tsx @@ -1,17 +1,39 @@ -import React, { forwardRef, useState } from 'react'; +import React, { forwardRef, useMemo, useRef, useState } from 'react'; import mergeRefs from 'react-merge-refs'; +import { Virtuoso } from 'react-virtuoso'; import { ResizeObserver as ResizeObserverPolyfill } from '@juggle/resize-observer'; import cn from 'classnames'; +import endOfDay from 'date-fns/endOfDay'; +import isAfter from 'date-fns/isAfter'; +import isSameDay from 'date-fns/isSameDay'; +import isSameMonth from 'date-fns/isSameMonth'; +import isThisMonth from 'date-fns/isThisMonth'; +import lastDayOfMonth from 'date-fns/lastDayOfMonth'; +import startOfDay from 'date-fns/startOfDay'; +import startOfMonth from 'date-fns/startOfMonth'; import { ButtonMobile } from '@alfalab/core-components-button/mobile'; import { ModalMobile } from '@alfalab/core-components-modal/mobile'; import { getDataTestId } from '@alfalab/core-components-shared'; +import { Typography } from '@alfalab/core-components-typography'; import { CalendarDesktop } from '../../desktop'; -import { isRangeValue, WEEKDAYS } from '../../utils'; +import { Month } from '../../typings'; +import { useCalendar } from '../../useCalendar'; +import { useRange } from '../../useRange'; +import { + addonArrayToHashTable, + dateArrayToHashTable, + generateMonths, + generateWeeks, + isRangeValue, + limitDate, + monthName, + WEEKDAYS, +} from '../../utils'; +import { DaysTable } from '../days-table'; -import { CalendarMonthOnlyView } from './components/calendar-month-only-view'; -import { CalendarMobileProps } from './typings'; +import { CalendarContentProps, CalendarMobileProps } from './typings'; import backdropTransitionStyles from './backdrop-transitions.module.css'; import styles from './index.module.css'; @@ -22,6 +44,261 @@ if (typeof window !== 'undefined' && !window.ResizeObserver) { window.ResizeObserver = ResizeObserverPolyfill; } +export const CalendarMonthOnlyView = ({ + value, + mode = 'single', + rangeBehavior = 'clarification', + month: monthTimestamp, + minDate: minDateTimestamp, + maxDate: maxDateTimestamp, + defaultMonth: defaultMonthTimestamp, + offDays, + events, + holidays, + onChange, + onMonthTitleClick, + selectedFrom, + selectedTo, + yearsAmount = 3, + dayAddons, + shape = 'rounded', + scrollableContainer, + clickableMonth, +}: CalendarContentProps & { + /** + * FIXME нужно сделать для компонента CalendarMonthOnlyView отдельный тип пропсов, т.к. тип CalendarContentProps intersection для типа CalendarMobileProps + * FIXME это приводит к тому, что в доку сторибука попадают типы пропсов, которые нужны для работы компонента CalendarMonthOnlyView, но не нужны для компонента CalendarMobile + * TODO Вынести компонент CalendarMonthOnlyView в отдельный файл + */ + clickableMonth?: boolean; +}) => { + const range = useRange({ + mode, + value, + selectedFrom, + selectedTo, + rangeBehavior, + onChange, + }); + + const month = useMemo( + () => (monthTimestamp ? new Date(monthTimestamp) : undefined), + [monthTimestamp], + ); + + const minDate = useMemo( + () => (minDateTimestamp ? startOfDay(minDateTimestamp) : undefined), + [minDateTimestamp], + ); + + const maxDate = useMemo(() => { + // блокируем последующие дни после текущего + if (clickableMonth && !maxDateTimestamp) { + return new Date(); + } + + return maxDateTimestamp ? endOfDay(maxDateTimestamp) : undefined; + }, [maxDateTimestamp, clickableMonth]); + + const selected = useMemo( + () => (range.value ? new Date(range.value) : undefined), + [range.value], + ); + + const startingDate = useRef(range.value); + + const defaultMonth = useMemo( + () => + startOfMonth( + selected || + limitDate( + defaultMonthTimestamp || Date.now(), + minDateTimestamp, + maxDateTimestamp, + ), + ), + [defaultMonthTimestamp, maxDateTimestamp, minDateTimestamp, selected], + ); + + const { activeMonth, highlighted, getDayProps } = useCalendar({ + month, + defaultMonth, + view: 'months', + minDate, + maxDate, + selected, + offDays, + events, + onChange: range.onChange, + dayAddons, + }); + + const activeMonths = useMemo(() => { + const eventsMap = dateArrayToHashTable(events || []); + const offDaysMap = dateArrayToHashTable(offDays || []); + const holidaysMap = dateArrayToHashTable(holidays || []); + const dayAddonsMap = addonArrayToHashTable(dayAddons || []); + + const prevMonths: Month[] = []; + const nextMonths: Month[] = []; + + const date = startingDate.current ? new Date(startingDate.current) : new Date(); + const currentYear = date.getFullYear(); + const currYearMonths = generateMonths(date, {}); + + for (let i = 0; i < yearsAmount; i++) { + const prevYear = date.setFullYear(currentYear - (i + 1)); + const nextYear = date.setFullYear(currentYear + (i + 1)); + + const prevYearMonths = generateMonths(new Date(prevYear), {}); + const nextYearMonths = generateMonths(new Date(nextYear), {}); + + prevMonths.unshift(...prevYearMonths); + nextMonths.push(...nextYearMonths); + } + + const generatedMonths = [...prevMonths, ...currYearMonths, ...nextMonths]; + + return generatedMonths.map((item) => ({ + ...item, + weeks: generateWeeks(item.date, { + minDate, + maxDate, + selected, + eventsMap, + offDaysMap, + holidaysMap, + dayAddonsMap, + }), + title: `${monthName(item.date)} ${item.date.getFullYear()}`, + })); + }, [events, offDays, holidays, dayAddons, minDate, maxDate, yearsAmount, selected]); + + const initialMonthIndex = useMemo(() => { + const date = range.value || range.selectedFrom || activeMonth.getTime() || Date.now(); + + return activeMonths.findIndex((m) => isSameMonth(date, m.date)); + }, [range.value, range.selectedFrom, activeMonth, activeMonths]); + + // заголовок должен становиться активным если выбран весь доступный период в месяце + const isMonthActive = (currentMonthIndex: number) => { + if (value && isRangeValue(value)) { + const { date: initialMonthDate } = activeMonths[initialMonthIndex]; + const { date: currentMonthDate } = activeMonths[currentMonthIndex]; + + const firstAvailableDayOfMonth = startOfMonth(initialMonthDate).getTime(); + /** + * последний доступный день месяца в timestamp + * представлен в виде последнего календарного дня, либо в виде текущей даты для актуального месяца + */ + const lastAvailableDayOfMonth = isThisMonth(initialMonthDate) + ? startOfDay(new Date()).getTime() + : lastDayOfMonth(initialMonthDate).getTime(); + const { dateFrom, dateTo } = value; + + if ( + dateFrom && + dateTo && + isSameMonth(initialMonthDate, currentMonthDate) && + isSameDay(firstAvailableDayOfMonth, dateFrom) && + isSameDay(lastAvailableDayOfMonth, dateTo) + ) { + return true; + } + } + + return false; + }; + + const handleClickMonthLabel = (index: number) => { + if (onChange) { + const { date } = activeMonths[index]; + const firstAvailableDayOfMonth = startOfMonth(date).getTime(); + const lastAvailableDayOfMonth = isThisMonth(date) + ? startOfDay(new Date()).getTime() + : lastDayOfMonth(date).getTime(); + + if (isMonthActive(index)) { + onChange(); + } else { + onChange(firstAvailableDayOfMonth, lastAvailableDayOfMonth); + } + } + }; + + const getMonthLabel = (index: number, isClickableMonth?: boolean) => { + if (isClickableMonth) { + return ( + + {activeMonths[index].title} + + ); + } + + return `\u00A0${activeMonths[index].title}\u00A0`; + }; + + const renderMonth = (index: number) => { + const isAfterDate = isAfter(activeMonths[index].date, activeMonth); + + return ( +
+ {onMonthTitleClick ? ( + /* eslint-disable-next-line jsx-a11y/click-events-have-key-events */ + + {activeMonths[index].title} + + ) : ( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions + handleClickMonthLabel(index) })} + > + {getMonthLabel(index, clickableMonth)} + + )} + +
+ ); + }; + + return ( + el.getBoundingClientRect().height + 32} + customScrollParent={scrollableContainer} + useWindowScroll={true} + className={styles.virtuoso} + /> + ); +}; + export const CalendarMonthOnlyViewHeader = () => (
diff --git a/packages/calendar/src/components/calendar-mobile/components/calendar-month-only-view/calendarMonthOnlyView.tsx b/packages/calendar/src/components/calendar-mobile/components/calendar-month-only-view/calendarMonthOnlyView.tsx deleted file mode 100644 index 87d7401139..0000000000 --- a/packages/calendar/src/components/calendar-mobile/components/calendar-month-only-view/calendarMonthOnlyView.tsx +++ /dev/null @@ -1,284 +0,0 @@ -import React, { useMemo, useRef } from 'react'; -import { Virtuoso } from 'react-virtuoso'; -import cn from 'classnames'; -import endOfDay from 'date-fns/endOfDay'; -import isAfter from 'date-fns/isAfter'; -import isSameDay from 'date-fns/isSameDay'; -import isSameMonth from 'date-fns/isSameMonth'; -import isThisMonth from 'date-fns/isThisMonth'; -import lastDayOfMonth from 'date-fns/lastDayOfMonth'; -import startOfDay from 'date-fns/startOfDay'; -import startOfMonth from 'date-fns/startOfMonth'; - -import { Typography } from '@alfalab/core-components-typography'; - -import { Month } from '../../../../typings'; -import { useCalendar } from '../../../../useCalendar'; -import { useRange } from '../../../../useRange'; -import { - addonArrayToHashTable, - dateArrayToHashTable, - generateMonths, - generateWeeks, - isRangeValue, - limitDate, - monthName, -} from '../../../../utils'; -import { DaysTable } from '../../../days-table'; -import { CalendarContentProps } from '../../typings'; - -import styles from '../../index.module.css'; - -export const CalendarMonthOnlyView = ({ - value, - mode = 'single', - rangeBehavior = 'clarification', - month: monthTimestamp, - minDate: minDateTimestamp, - maxDate: maxDateTimestamp, - defaultMonth: defaultMonthTimestamp, - offDays, - events, - holidays, - onChange, - onMonthTitleClick, - selectedFrom, - selectedTo, - yearsAmount = 3, - dayAddons, - shape = 'rounded', - scrollableContainer, - clickableMonth, -}: CalendarContentProps & { - /** - * FIXME нужно сделать для компонента CalendarMonthOnlyView отдельный тип пропсов, т.к. тип CalendarContentProps intersection для типа CalendarMobileProps - * FIXME это приводит к тому, что в доку сторибука попадают типы пропсов, которые нужны для работы компонента CalendarMonthOnlyView, но не нужны для компонента CalendarMobile - */ - clickableMonth?: boolean; -}) => { - const range = useRange({ - mode, - value, - selectedFrom, - selectedTo, - rangeBehavior, - onChange, - }); - - const month = useMemo( - () => (monthTimestamp ? new Date(monthTimestamp) : undefined), - [monthTimestamp], - ); - - const minDate = useMemo( - () => (minDateTimestamp ? startOfDay(minDateTimestamp) : undefined), - [minDateTimestamp], - ); - - const maxDate = useMemo(() => { - // блокируем последующие дни после текущего - if (clickableMonth && !maxDateTimestamp) { - return new Date(); - } - - return maxDateTimestamp ? endOfDay(maxDateTimestamp) : undefined; - }, [maxDateTimestamp, clickableMonth]); - - const selected = useMemo( - () => (range.value ? new Date(range.value) : undefined), - [range.value], - ); - - const startingDate = useRef(range.value); - - const defaultMonth = useMemo( - () => - startOfMonth( - selected || - limitDate( - defaultMonthTimestamp || Date.now(), - minDateTimestamp, - maxDateTimestamp, - ), - ), - [defaultMonthTimestamp, maxDateTimestamp, minDateTimestamp, selected], - ); - - const { activeMonth, highlighted, getDayProps } = useCalendar({ - month, - defaultMonth, - view: 'months', - minDate, - maxDate, - selected, - offDays, - events, - onChange: range.onChange, - dayAddons, - }); - - const activeMonths = useMemo(() => { - const eventsMap = dateArrayToHashTable(events || []); - const offDaysMap = dateArrayToHashTable(offDays || []); - const holidaysMap = dateArrayToHashTable(holidays || []); - const dayAddonsMap = addonArrayToHashTable(dayAddons || []); - - const prevMonths: Month[] = []; - const nextMonths: Month[] = []; - - const date = startingDate.current ? new Date(startingDate.current) : new Date(); - const currentYear = date.getFullYear(); - const currYearMonths = generateMonths(date, {}); - - for (let i = 0; i < yearsAmount; i++) { - const prevYear = date.setFullYear(currentYear - (i + 1)); - const nextYear = date.setFullYear(currentYear + (i + 1)); - - const prevYearMonths = generateMonths(new Date(prevYear), {}); - const nextYearMonths = generateMonths(new Date(nextYear), {}); - - prevMonths.unshift(...prevYearMonths); - nextMonths.push(...nextYearMonths); - } - - const generatedMonths = [...prevMonths, ...currYearMonths, ...nextMonths]; - - return generatedMonths.map((item) => ({ - ...item, - weeks: generateWeeks(item.date, { - minDate, - maxDate, - selected, - eventsMap, - offDaysMap, - holidaysMap, - dayAddonsMap, - }), - title: `${monthName(item.date)} ${item.date.getFullYear()}`, - })); - }, [events, offDays, holidays, dayAddons, minDate, maxDate, yearsAmount, selected]); - - const initialMonthIndex = useMemo(() => { - const date = range.value || range.selectedFrom || activeMonth.getTime() || Date.now(); - - return activeMonths.findIndex((m) => isSameMonth(date, m.date)); - }, [range.value, range.selectedFrom, activeMonth, activeMonths]); - - // заголовок должен становиться активным если выбран весь доступный период в месяце - const isMonthActive = (currentMonthIndex: number) => { - if (value && isRangeValue(value)) { - const { date: initialMonthDate } = activeMonths[initialMonthIndex]; - const { date: currentMonthDate } = activeMonths[currentMonthIndex]; - - const firstAvailableDayOfMonth = startOfMonth(initialMonthDate).getTime(); - /** - * последний доступный день месяца в timestamp - * представлен в виде последнего календарного дня, либо в виде текущей даты для актуального месяца - */ - const lastAvailableDayOfMonth = isThisMonth(initialMonthDate) - ? startOfDay(new Date()).getTime() - : lastDayOfMonth(initialMonthDate).getTime(); - const { dateFrom, dateTo } = value; - - if ( - dateFrom && - dateTo && - isSameMonth(initialMonthDate, currentMonthDate) && - isSameDay(firstAvailableDayOfMonth, dateFrom) && - isSameDay(lastAvailableDayOfMonth, dateTo) - ) { - return true; - } - } - - return false; - }; - - const handleClickMonthLabel = (index: number) => { - if (onChange) { - const { date } = activeMonths[index]; - const firstAvailableDayOfMonth = startOfMonth(date).getTime(); - const lastAvailableDayOfMonth = isThisMonth(date) - ? startOfDay(new Date()).getTime() - : lastDayOfMonth(date).getTime(); - - if (isMonthActive(index)) { - onChange(); - } else { - onChange(firstAvailableDayOfMonth, lastAvailableDayOfMonth); - } - } - }; - - const getMonthLabel = (index: number, isClickableMonth?: boolean) => { - if (isClickableMonth) { - return ( - - {activeMonths[index].title} - - ); - } - - return `\u00A0${activeMonths[index].title}\u00A0`; - }; - - const renderMonth = (index: number) => { - const isAfterDate = isAfter(activeMonths[index].date, activeMonth); - - return ( -
- {onMonthTitleClick ? ( - /* eslint-disable-next-line jsx-a11y/click-events-have-key-events */ - - {activeMonths[index].title} - - ) : ( - // eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions - handleClickMonthLabel(index) })} - > - {getMonthLabel(index, clickableMonth)} - - )} - -
- ); - }; - - return ( - el.getBoundingClientRect().height + 32} - customScrollParent={scrollableContainer} - useWindowScroll={true} - className={styles.virtuoso} - /> - ); -}; diff --git a/packages/calendar/src/components/calendar-mobile/components/calendar-month-only-view/index.ts b/packages/calendar/src/components/calendar-mobile/components/calendar-month-only-view/index.ts deleted file mode 100644 index 2133cd05a3..0000000000 --- a/packages/calendar/src/components/calendar-mobile/components/calendar-month-only-view/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { CalendarMonthOnlyView } from './calendarMonthOnlyView'; diff --git a/packages/calendar/src/components/calendar-mobile/index.ts b/packages/calendar/src/components/calendar-mobile/index.ts index 38a77257e5..fc34df14b2 100644 --- a/packages/calendar/src/components/calendar-mobile/index.ts +++ b/packages/calendar/src/components/calendar-mobile/index.ts @@ -1,3 +1,2 @@ export * from './Component'; export * from './typings'; -export * from './components/calendar-month-only-view'; From f514b177e13c083141cb1ee5125b280bf6ac91f8 Mon Sep 17 00:00:00 2001 From: Mikhail Lukin Date: Tue, 10 Dec 2024 14:22:20 +0700 Subject: [PATCH 7/7] fix(calendar): fix after merge conflict --- .../components/calendar-mobile/Component.tsx | 304 +----------------- .../calendarMonthOnlyView.tsx | 299 +++++++++++++++++ .../calendar-month-only-view/index.ts | 1 + .../src/components/calendar-mobile/index.ts | 1 + 4 files changed, 305 insertions(+), 300 deletions(-) create mode 100644 packages/calendar/src/components/calendar-mobile/components/calendar-month-only-view/calendarMonthOnlyView.tsx create mode 100644 packages/calendar/src/components/calendar-mobile/components/calendar-month-only-view/index.ts diff --git a/packages/calendar/src/components/calendar-mobile/Component.tsx b/packages/calendar/src/components/calendar-mobile/Component.tsx index 77a0545e35..046ba50c54 100644 --- a/packages/calendar/src/components/calendar-mobile/Component.tsx +++ b/packages/calendar/src/components/calendar-mobile/Component.tsx @@ -1,38 +1,17 @@ -import React, { forwardRef, useMemo, useRef, useState } from 'react'; +import React, { forwardRef, useState } from 'react'; import mergeRefs from 'react-merge-refs'; -import { Virtuoso } from 'react-virtuoso'; import { ResizeObserver as ResizeObserverPolyfill } from '@juggle/resize-observer'; import cn from 'classnames'; -import endOfDay from 'date-fns/endOfDay'; -import isAfter from 'date-fns/isAfter'; -import isSameMonth from 'date-fns/isSameMonth'; -import startOfDay from 'date-fns/startOfDay'; -import startOfMonth from 'date-fns/startOfMonth'; import { ButtonMobile } from '@alfalab/core-components-button/mobile'; import { ModalMobile } from '@alfalab/core-components-modal/mobile'; import { getDataTestId } from '@alfalab/core-components-shared'; -import { Typography } from '@alfalab/core-components-typography'; import { CalendarDesktop } from '../../desktop'; -import { Month } from '../../typings'; -import { useCalendar } from '../../useCalendar'; -import { useRange } from '../../useRange'; -import { - addonArrayToHashTable, - dateArrayToHashTable, - generateMonths, - generateWeeks, - getMonthEndTimestamp, - getMonthStartTimestamp, - isRangeValue, - limitDate, - monthName, - WEEKDAYS, -} from '../../utils'; -import { DaysTable } from '../days-table'; +import { isRangeValue, WEEKDAYS } from '../../utils'; -import { CalendarContentProps, CalendarMobileProps } from './typings'; +import { CalendarMonthOnlyView } from './components/calendar-month-only-view'; +import { CalendarMobileProps } from './typings'; import backdropTransitionStyles from './backdrop-transitions.module.css'; import styles from './index.module.css'; @@ -43,281 +22,6 @@ if (typeof window !== 'undefined' && !window.ResizeObserver) { window.ResizeObserver = ResizeObserverPolyfill; } -export const CalendarMonthOnlyView = ({ - value, - mode = 'single', - rangeBehavior = 'clarification', - month: monthTimestamp, - minDate: minDateTimestamp, - maxDate: maxDateTimestamp, - defaultMonth: defaultMonthTimestamp, - offDays, - events, - holidays, - onChange, - onMonthTitleClick, - selectedFrom, - selectedTo, - yearsAmount = 3, - dayAddons, - shape = 'rounded', - scrollableContainer, - clickableMonth, -}: CalendarContentProps & { - /** - * FIXME нужно сделать для компонента CalendarMonthOnlyView отдельный тип пропсов, т.к. тип CalendarContentProps intersection для типа CalendarMobileProps - * FIXME это приводит к тому, что в доку сторибука попадают типы пропсов, которые нужны для работы компонента CalendarMonthOnlyView, но не нужны для компонента CalendarMobile - * TODO Вынести компонент CalendarMonthOnlyView в отдельный файл - */ - clickableMonth?: boolean; -}) => { - const range = useRange({ - mode, - value, - selectedFrom, - selectedTo, - rangeBehavior, - onChange, - }); - - const month = useMemo( - () => (monthTimestamp ? new Date(monthTimestamp) : undefined), - [monthTimestamp], - ); - - const minDate = useMemo( - () => (minDateTimestamp ? startOfDay(minDateTimestamp) : undefined), - [minDateTimestamp], - ); - - const maxDate = useMemo(() => { - // блокируем последующие дни после текущего - if (clickableMonth && !maxDateTimestamp) { - return new Date(); - } - - return maxDateTimestamp ? endOfDay(maxDateTimestamp) : undefined; - }, [maxDateTimestamp, clickableMonth]); - - const selected = useMemo( - () => (range.value ? new Date(range.value) : undefined), - [range.value], - ); - - const startingDate = useRef(range.value); - - const defaultMonth = useMemo( - () => - startOfMonth( - selected || - limitDate( - defaultMonthTimestamp || Date.now(), - minDateTimestamp, - maxDateTimestamp, - ), - ), - [defaultMonthTimestamp, maxDateTimestamp, minDateTimestamp, selected], - ); - - const { activeMonth, highlighted, getDayProps } = useCalendar({ - month, - defaultMonth, - view: 'months', - minDate, - maxDate, - selected, - offDays, - events, - onChange: range.onChange, - dayAddons, - }); - - const activeMonths = useMemo(() => { - const eventsMap = dateArrayToHashTable(events || []); - const offDaysMap = dateArrayToHashTable(offDays || []); - const holidaysMap = dateArrayToHashTable(holidays || []); - const dayAddonsMap = addonArrayToHashTable(dayAddons || []); - - const prevMonths: Month[] = []; - const nextMonths: Month[] = []; - - const date = startingDate.current ? new Date(startingDate.current) : new Date(); - const currentYear = date.getFullYear(); - const currYearMonths = generateMonths(date, {}); - - for (let i = 0; i < yearsAmount; i++) { - const prevYear = date.setFullYear(currentYear - (i + 1)); - const nextYear = date.setFullYear(currentYear + (i + 1)); - - const prevYearMonths = generateMonths(new Date(prevYear), {}); - const nextYearMonths = generateMonths(new Date(nextYear), {}); - - prevMonths.unshift(...prevYearMonths); - nextMonths.push(...nextYearMonths); - } - - const generatedMonths = [...prevMonths, ...currYearMonths, ...nextMonths]; - - return generatedMonths.map((item) => ({ - ...item, - weeks: generateWeeks(item.date, { - minDate, - maxDate, - selected, - eventsMap, - offDaysMap, - holidaysMap, - dayAddonsMap, - }), - title: `${monthName(item.date)} ${item.date.getFullYear()}`, - })); - }, [events, offDays, holidays, dayAddons, minDate, maxDate, yearsAmount, selected]); - - const initialMonthIndex = useMemo(() => { - const date = range.value || range.selectedFrom || activeMonth.getTime() || Date.now(); - - return activeMonths.findIndex((m) => isSameMonth(date, m.date)); - }, [range.value, range.selectedFrom, activeMonth, activeMonths]); - - // заголовок должен становиться активным, если выбран весь доступный период в месяце - const isMonthActive = (currentMonthIndex: number): boolean => { - if (!value || !isRangeValue(value) || !value.dateFrom || !value.dateTo) { - return false; - } - - const { dateFrom, dateTo } = value; - - const { date: currentMonthDate } = activeMonths[currentMonthIndex]; - const monthStartTimestamp = getMonthStartTimestamp(currentMonthDate); - const monthEndTimestamp = getMonthEndTimestamp(currentMonthDate); - - // Проверяем, что выбранный диапазон полностью покрывает месяц - return dateFrom <= monthStartTimestamp && dateTo >= monthEndTimestamp; - }; - - const handleClickMonthLabel = (index: number) => { - if (!onChange) return; - - const { date: dateActiveMonths } = activeMonths[index]; - - // Вычисляем начало и конец месяца, по которому был произведен клик - const clickedMonthStartTimestamp = getMonthStartTimestamp(dateActiveMonths); - const clickedMonthEndTimestamp = getMonthEndTimestamp(dateActiveMonths); - - // Если значение не определено или не является диапазоном, то устанавливаем новый диапазон - if (!value || !isRangeValue(value) || !value.dateFrom || !value.dateTo) { - onChange(clickedMonthStartTimestamp, clickedMonthEndTimestamp); - - return; - } - - // Выбранный диапазон дат - const { dateFrom, dateTo } = value; - const selectedRangeStartDate = new Date(dateFrom); - const selectedRangeEndDate = new Date(dateTo); - const selectedRangeStartTimestamp = getMonthStartTimestamp(selectedRangeStartDate); - const selectedRangeEndTimestamp = getMonthEndTimestamp(selectedRangeEndDate); - - // Проверяем, является ли выбранный диапазон одним и тем же месяцем - const isSingleMonthSelected = - isSameMonth(selectedRangeStartDate, selectedRangeEndDate) && - dateFrom <= selectedRangeStartTimestamp && - dateTo >= selectedRangeEndTimestamp; - // Проверяем, является ли кликнутый месяц таким же, что и выбранный диапазон - const isSameMonthClicked = isSameMonth(selectedRangeStartDate, dateActiveMonths); - // Проверяем, находится ли кликнутый месяц внутри выбранного диапазона - const isClickedMonthInsideRange = - clickedMonthEndTimestamp >= selectedRangeStartTimestamp && - clickedMonthStartTimestamp <= selectedRangeEndTimestamp; - // Проверяем находится ли выбранный диапазон в пределах кликнутого месяца - const isRangeWithinSingleMonth = - selectedRangeStartTimestamp === dateFrom && selectedRangeEndTimestamp === dateTo; - - if (isSingleMonthSelected && isSameMonthClicked) { - onChange(); - } else if (isClickedMonthInsideRange || !isRangeWithinSingleMonth) { - onChange(clickedMonthStartTimestamp, clickedMonthEndTimestamp); - } else { - const newDateFrom = Math.min(selectedRangeStartTimestamp, clickedMonthStartTimestamp); - const newDateTo = Math.max(selectedRangeEndTimestamp, clickedMonthEndTimestamp); - - onChange(newDateFrom, newDateTo); - } - }; - - const getMonthLabel = (index: number, isClickableMonth?: boolean) => { - if (isClickableMonth) { - return ( - - {activeMonths[index].title} - - ); - } - - return `\u00A0${activeMonths[index].title}\u00A0`; - }; - - const renderMonth = (index: number) => { - const isAfterDate = isAfter(activeMonths[index].date, maxDate ?? new Date()); - - return ( -
- {onMonthTitleClick ? ( - /* eslint-disable-next-line jsx-a11y/click-events-have-key-events */ - - {activeMonths[index].title} - - ) : ( - // eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions - handleClickMonthLabel(index) })} - > - {getMonthLabel(index, clickableMonth)} - - )} - -
- ); - }; - - return ( - el.getBoundingClientRect().height + 32} - customScrollParent={scrollableContainer} - useWindowScroll={true} - className={styles.virtuoso} - /> - ); -}; - export const CalendarMonthOnlyViewHeader = () => (
diff --git a/packages/calendar/src/components/calendar-mobile/components/calendar-month-only-view/calendarMonthOnlyView.tsx b/packages/calendar/src/components/calendar-mobile/components/calendar-month-only-view/calendarMonthOnlyView.tsx new file mode 100644 index 0000000000..b39f4a0321 --- /dev/null +++ b/packages/calendar/src/components/calendar-mobile/components/calendar-month-only-view/calendarMonthOnlyView.tsx @@ -0,0 +1,299 @@ +import React, { useMemo, useRef } from 'react'; +import { Virtuoso } from 'react-virtuoso'; +import cn from 'classnames'; +import endOfDay from 'date-fns/endOfDay'; +import isAfter from 'date-fns/isAfter'; +import isSameMonth from 'date-fns/isSameMonth'; +import startOfDay from 'date-fns/startOfDay'; +import startOfMonth from 'date-fns/startOfMonth'; + +import { Typography } from '@alfalab/core-components-typography'; + +import { Month } from '../../../../typings'; +import { useCalendar } from '../../../../useCalendar'; +import { useRange } from '../../../../useRange'; +import { + addonArrayToHashTable, + dateArrayToHashTable, + generateMonths, + generateWeeks, + getMonthEndTimestamp, + getMonthStartTimestamp, + isRangeValue, + limitDate, + monthName, +} from '../../../../utils'; +import { DaysTable } from '../../../days-table'; +import { CalendarContentProps } from '../../typings'; + +import styles from '../../index.module.css'; + +export const CalendarMonthOnlyView = ({ + value, + mode = 'single', + rangeBehavior = 'clarification', + month: monthTimestamp, + minDate: minDateTimestamp, + maxDate: maxDateTimestamp, + defaultMonth: defaultMonthTimestamp, + offDays, + events, + holidays, + onChange, + onMonthTitleClick, + selectedFrom, + selectedTo, + yearsAmount = 3, + dayAddons, + shape = 'rounded', + scrollableContainer, + clickableMonth, +}: CalendarContentProps & { + clickableMonth?: boolean; +}) => { + const range = useRange({ + mode, + value, + selectedFrom, + selectedTo, + rangeBehavior, + onChange, + }); + + const month = useMemo( + () => (monthTimestamp ? new Date(monthTimestamp) : undefined), + [monthTimestamp], + ); + + const minDate = useMemo( + () => (minDateTimestamp ? startOfDay(minDateTimestamp) : undefined), + [minDateTimestamp], + ); + + const maxDate = useMemo(() => { + // блокируем последующие дни после текущего + if (clickableMonth && !maxDateTimestamp) { + return new Date(); + } + + return maxDateTimestamp ? endOfDay(maxDateTimestamp) : undefined; + }, [maxDateTimestamp, clickableMonth]); + + const selected = useMemo( + () => (range.value ? new Date(range.value) : undefined), + [range.value], + ); + + const startingDate = useRef(range.value); + + const defaultMonth = useMemo( + () => + startOfMonth( + selected || + limitDate( + defaultMonthTimestamp || Date.now(), + minDateTimestamp, + maxDateTimestamp, + ), + ), + [defaultMonthTimestamp, maxDateTimestamp, minDateTimestamp, selected], + ); + + const { activeMonth, highlighted, getDayProps } = useCalendar({ + month, + defaultMonth, + view: 'months', + minDate, + maxDate, + selected, + offDays, + events, + onChange: range.onChange, + dayAddons, + }); + + const activeMonths = useMemo(() => { + const eventsMap = dateArrayToHashTable(events || []); + const offDaysMap = dateArrayToHashTable(offDays || []); + const holidaysMap = dateArrayToHashTable(holidays || []); + const dayAddonsMap = addonArrayToHashTable(dayAddons || []); + + const prevMonths: Month[] = []; + const nextMonths: Month[] = []; + + const date = startingDate.current ? new Date(startingDate.current) : new Date(); + const currentYear = date.getFullYear(); + const currYearMonths = generateMonths(date, {}); + + for (let i = 0; i < yearsAmount; i++) { + const prevYear = date.setFullYear(currentYear - (i + 1)); + const nextYear = date.setFullYear(currentYear + (i + 1)); + + const prevYearMonths = generateMonths(new Date(prevYear), {}); + const nextYearMonths = generateMonths(new Date(nextYear), {}); + + prevMonths.unshift(...prevYearMonths); + nextMonths.push(...nextYearMonths); + } + + const generatedMonths = [...prevMonths, ...currYearMonths, ...nextMonths]; + + return generatedMonths.map((item) => ({ + ...item, + weeks: generateWeeks(item.date, { + minDate, + maxDate, + selected, + eventsMap, + offDaysMap, + holidaysMap, + dayAddonsMap, + }), + title: `${monthName(item.date)} ${item.date.getFullYear()}`, + })); + }, [events, offDays, holidays, dayAddons, minDate, maxDate, yearsAmount, selected]); + + const initialMonthIndex = useMemo(() => { + const date = range.value || range.selectedFrom || activeMonth.getTime() || Date.now(); + + return activeMonths.findIndex((m) => isSameMonth(date, m.date)); + }, [range.value, range.selectedFrom, activeMonth, activeMonths]); + + // заголовок должен становиться активным, если выбран весь доступный период в месяце + const isMonthActive = (currentMonthIndex: number): boolean => { + if (!value || !isRangeValue(value) || !value.dateFrom || !value.dateTo) { + return false; + } + + const { dateFrom, dateTo } = value; + + const { date: currentMonthDate } = activeMonths[currentMonthIndex]; + const monthStartTimestamp = getMonthStartTimestamp(currentMonthDate); + const monthEndTimestamp = getMonthEndTimestamp(currentMonthDate); + + // Проверяем, что выбранный диапазон полностью покрывает месяц + return dateFrom <= monthStartTimestamp && dateTo >= monthEndTimestamp; + }; + + const handleClickMonthLabel = (index: number) => { + if (!onChange) return; + + const { date: dateActiveMonths } = activeMonths[index]; + + // Вычисляем начало и конец месяца, по которому был произведен клик + const clickedMonthStartTimestamp = getMonthStartTimestamp(dateActiveMonths); + const clickedMonthEndTimestamp = getMonthEndTimestamp(dateActiveMonths); + + // Если значение не определено или не является диапазоном, то устанавливаем новый диапазон + if (!value || !isRangeValue(value) || !value.dateFrom || !value.dateTo) { + onChange(clickedMonthStartTimestamp, clickedMonthEndTimestamp); + + return; + } + + // Выбранный диапазон дат + const { dateFrom, dateTo } = value; + const selectedRangeStartDate = new Date(dateFrom); + const selectedRangeEndDate = new Date(dateTo); + const selectedRangeStartTimestamp = getMonthStartTimestamp(selectedRangeStartDate); + const selectedRangeEndTimestamp = getMonthEndTimestamp(selectedRangeEndDate); + + // Проверяем, является ли выбранный диапазон одним и тем же месяцем + const isSingleMonthSelected = + isSameMonth(selectedRangeStartDate, selectedRangeEndDate) && + dateFrom <= selectedRangeStartTimestamp && + dateTo >= selectedRangeEndTimestamp; + // Проверяем, является ли кликнутый месяц таким же, что и выбранный диапазон + const isSameMonthClicked = isSameMonth(selectedRangeStartDate, dateActiveMonths); + // Проверяем, находится ли кликнутый месяц внутри выбранного диапазона + const isClickedMonthInsideRange = + clickedMonthEndTimestamp >= selectedRangeStartTimestamp && + clickedMonthStartTimestamp <= selectedRangeEndTimestamp; + // Проверяем находится ли выбранный диапазон в пределах кликнутого месяца + const isRangeWithinSingleMonth = + selectedRangeStartTimestamp === dateFrom && selectedRangeEndTimestamp === dateTo; + + if (isSingleMonthSelected && isSameMonthClicked) { + onChange(); + } else if (isClickedMonthInsideRange || !isRangeWithinSingleMonth) { + onChange(clickedMonthStartTimestamp, clickedMonthEndTimestamp); + } else { + const newDateFrom = Math.min(selectedRangeStartTimestamp, clickedMonthStartTimestamp); + const newDateTo = Math.max(selectedRangeEndTimestamp, clickedMonthEndTimestamp); + + onChange(newDateFrom, newDateTo); + } + }; + + const getMonthLabel = (index: number, isClickableMonth?: boolean) => { + if (isClickableMonth) { + return ( + + {activeMonths[index].title} + + ); + } + + return `\u00A0${activeMonths[index].title}\u00A0`; + }; + + const renderMonth = (index: number) => { + const isAfterDate = isAfter(activeMonths[index].date, maxDate ?? new Date()); + + return ( +
+ {onMonthTitleClick ? ( + /* eslint-disable-next-line jsx-a11y/click-events-have-key-events */ + + {activeMonths[index].title} + + ) : ( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions + handleClickMonthLabel(index) })} + > + {getMonthLabel(index, clickableMonth)} + + )} + +
+ ); + }; + + return ( + el.getBoundingClientRect().height + 32} + customScrollParent={scrollableContainer} + useWindowScroll={true} + className={styles.virtuoso} + /> + ); +}; diff --git a/packages/calendar/src/components/calendar-mobile/components/calendar-month-only-view/index.ts b/packages/calendar/src/components/calendar-mobile/components/calendar-month-only-view/index.ts new file mode 100644 index 0000000000..2133cd05a3 --- /dev/null +++ b/packages/calendar/src/components/calendar-mobile/components/calendar-month-only-view/index.ts @@ -0,0 +1 @@ +export { CalendarMonthOnlyView } from './calendarMonthOnlyView'; diff --git a/packages/calendar/src/components/calendar-mobile/index.ts b/packages/calendar/src/components/calendar-mobile/index.ts index fc34df14b2..38a77257e5 100644 --- a/packages/calendar/src/components/calendar-mobile/index.ts +++ b/packages/calendar/src/components/calendar-mobile/index.ts @@ -1,2 +1,3 @@ export * from './Component'; export * from './typings'; +export * from './components/calendar-month-only-view';