diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 17852825bc32f..3e099bc5682ef 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -8723,8 +8723,11 @@ const CONST = { }, CALENDAR_PICKER: { YEAR_PICKER: 'CalendarPicker-YearPicker', + MONTH_PICKER: 'CalendarPicker-MonthPicker', PREV_MONTH: 'CalendarPicker-PrevMonth', NEXT_MONTH: 'CalendarPicker-NextMonth', + PREV_YEAR: 'CalendarPicker-PrevYear', + NEXT_YEAR: 'CalendarPicker-NextYear', DAY: 'CalendarPicker-Day', }, REPORT_DETAILS: { diff --git a/src/components/DatePicker/CalendarPicker/MonthPickerModal.tsx b/src/components/DatePicker/CalendarPicker/MonthPickerModal.tsx new file mode 100644 index 0000000000000..9c565710b9043 --- /dev/null +++ b/src/components/DatePicker/CalendarPicker/MonthPickerModal.tsx @@ -0,0 +1,131 @@ +import {endOfMonth, startOfMonth} from 'date-fns'; +import React, {useEffect, useMemo, useState} from 'react'; +import {Keyboard} from 'react-native'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import Modal from '@components/Modal'; +import ScreenWrapper from '@components/ScreenWrapper'; +import SelectionList from '@components/SelectionList'; +import RadioListItem from '@components/SelectionList/ListItem/RadioListItem'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import DateUtils from '@libs/DateUtils'; +import CONST from '@src/CONST'; + +type MonthPickerModalProps = { + /** Whether the modal is visible */ + isVisible: boolean; + + /** Currently selected month (0-indexed) */ + currentMonth?: number; + + /** The year currently being viewed */ + currentYear?: number; + + /** A minimum date (oldest) allowed to select */ + minDate?: Date; + + /** A maximum date (earliest) allowed to select */ + maxDate?: Date; + + /** Function to call when the user selects a month */ + onMonthChange?: (month: number) => void; + + /** Function to call when the user closes the month picker */ + onClose?: () => void; +}; + +function MonthPickerModal({isVisible, currentMonth = new Date().getMonth(), currentYear = new Date().getFullYear(), minDate, maxDate, onMonthChange, onClose}: MonthPickerModalProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const [searchText, setSearchText] = useState(''); + const monthNames = DateUtils.getMonthNames(); + + const allMonths = useMemo(() => { + const minMonthStart = minDate ? startOfMonth(new Date(minDate)) : undefined; + const maxMonthEnd = maxDate ? endOfMonth(new Date(maxDate)) : undefined; + + return monthNames + .map((month, index) => { + const monthStart = startOfMonth(new Date(currentYear, index)); + const monthEnd = endOfMonth(new Date(currentYear, index)); + const isBeforeMin = minMonthStart ? monthEnd < minMonthStart : false; + const isAfterMax = maxMonthEnd ? monthStart > maxMonthEnd : false; + if (isBeforeMin || isAfterMax) { + return null; + } + return { + text: month.charAt(0).toUpperCase() + month.slice(1), + value: index, + keyForList: index.toString(), + isSelected: index === currentMonth, + }; + }) + .filter((item): item is NonNullable => item !== null); + }, [monthNames, currentMonth, currentYear, minDate, maxDate]); + + const {data, headerMessage} = useMemo(() => { + const filteredMonths = searchText === '' ? allMonths : allMonths.filter((month) => month.text.toLowerCase().includes(searchText.toLowerCase())); + return { + headerMessage: !filteredMonths.length ? translate('common.noResultsFound') : '', + data: filteredMonths, + }; + }, [allMonths, searchText, translate]); + + useEffect(() => { + if (isVisible) { + return; + } + setSearchText(''); + }, [isVisible]); + + const textInputOptions = useMemo( + () => ({ + label: translate('monthPickerPage.selectMonth'), + value: searchText, + onChangeText: setSearchText, + headerMessage, + }), + [headerMessage, searchText, translate], + ); + + return ( + onClose?.()} + onModalHide={onClose} + shouldHandleNavigationBack + shouldUseCustomBackdrop + onBackdropPress={onClose} + enableEdgeToEdgeBottomSafeAreaPadding + > + + + { + Keyboard.dismiss(); + onMonthChange?.(option.value); + }} + textInputOptions={textInputOptions} + initiallyFocusedItemKey={currentMonth.toString()} + disableMaintainingScrollPosition + addBottomSafeAreaPadding + shouldStopPropagation + showScrollIndicator + /> + + + ); +} + +export default MonthPickerModal; diff --git a/src/components/DatePicker/CalendarPicker/index.tsx b/src/components/DatePicker/CalendarPicker/index.tsx index 0364dc61c5ec9..18c3260030d57 100644 --- a/src/components/DatePicker/CalendarPicker/index.tsx +++ b/src/components/DatePicker/CalendarPicker/index.tsx @@ -1,4 +1,22 @@ -import {addMonths, endOfDay, endOfMonth, format, getYear, isSameDay, parseISO, setDate, setYear, startOfDay, startOfMonth, subMonths} from 'date-fns'; +import { + addMonths, + addYears, + endOfDay, + endOfMonth, + endOfYear, + format, + getYear, + isSameDay, + parseISO, + setDate, + setMonth, + setYear, + startOfDay, + startOfMonth, + startOfYear, + subMonths, + subYears, +} from 'date-fns'; import {Str} from 'expensify-common'; import React, {useCallback, useEffect, useRef, useState} from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; @@ -15,6 +33,7 @@ import CONST from '@src/CONST'; import ArrowIcon from './ArrowIcon'; import Day from './Day'; import generateMonthMatrix from './generateMonthMatrix'; +import MonthPickerModal from './MonthPickerModal'; import type CalendarPickerListItem from './types'; import YearPickerModal from './YearPickerModal'; @@ -68,8 +87,10 @@ function CalendarPicker({ const themeStyles = useThemeStyles(); const {translate} = useLocalize(); const pressableRef = useRef(null); + const monthPressableRef = useRef(null); const [currentDateView, setCurrentDateView] = useState(() => getInitialCurrentDateView(value, minDate, maxDate)); const [isYearPickerVisible, setIsYearPickerVisible] = useState(false); + const [isMonthPickerVisible, setIsMonthPickerVisible] = useState(false); const isFirstRender = useRef(true); const currentMonthView = currentDateView.getMonth(); @@ -104,6 +125,11 @@ function CalendarPicker({ requestAnimationFrame(() => setIsYearPickerVisible(false)); }; + const onMonthSelected = (month: number) => { + setCurrentDateView((prev) => setMonth(new Date(prev), month)); + requestAnimationFrame(() => setIsMonthPickerVisible(false)); + }; + /** * Calls the onSelected function with the selected date. * @param day - The day of the month that was selected. @@ -155,10 +181,34 @@ function CalendarPicker({ }); }; + const moveToPrevYear = () => { + setCurrentDateView((prev) => { + let prevYear = subYears(new Date(prev), 1); + if (prevYear < new Date(minDate)) { + prevYear = new Date(minDate); + } + setYears((prevYears) => prevYears.map((item) => ({...item, isSelected: item.value === prevYear.getFullYear()}))); + return prevYear; + }); + }; + + const moveToNextYear = () => { + setCurrentDateView((prev) => { + let nextYear = addYears(new Date(prev), 1); + if (nextYear > new Date(maxDate)) { + nextYear = new Date(maxDate); + } + setYears((prevYears) => prevYears.map((item) => ({...item, isSelected: item.value === nextYear.getFullYear()}))); + return nextYear; + }); + }; + const monthNames = DateUtils.getMonthNames().map((month) => Str.UCFirst(month)); const daysOfWeek = DateUtils.getDaysOfWeek().map((day) => day.toUpperCase()); const hasAvailableDatesNextMonth = startOfDay(new Date(maxDate)) > endOfMonth(new Date(currentDateView)); const hasAvailableDatesPrevMonth = endOfDay(new Date(minDate)) < startOfMonth(new Date(currentDateView)); + const hasAvailableDatesNextYear = startOfDay(new Date(maxDate)) > endOfYear(new Date(currentDateView)); + const hasAvailableDatesPrevYear = endOfDay(new Date(minDate)) < startOfYear(new Date(currentDateView)); useEffect(() => { if (isSmallScreenWidth || isFirstRender.current) { @@ -190,44 +240,14 @@ function CalendarPicker({ style={[themeStyles.calendarHeader, themeStyles.flexRow, themeStyles.justifyContentBetween, themeStyles.alignItemsCenter, headerPaddingStyle]} dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}} > - { - pressableRef?.current?.blur(); - setIsYearPickerVisible(true); - }} - ref={pressableRef} - style={[themeStyles.alignItemsCenter, themeStyles.flexRow, themeStyles.flex1, themeStyles.justifyContentStart]} - wrapperStyle={[themeStyles.alignItemsCenter]} - hoverDimmingValue={1} - disabled={years.length <= 1} - testID="currentYearButton" - accessibilityLabel={`${currentYearView}, ${translate('common.currentYear')}`} - role={CONST.ROLE.BUTTON} - sentryLabel={CONST.SENTRY_LABEL.CALENDAR_PICKER.YEAR_PICKER} - > - - {currentYearView} - - - - - - {monthNames.at(currentMonthView)} - + @@ -236,19 +256,91 @@ function CalendarPicker({ direction={CONST.DIRECTION.LEFT} /> + { + monthPressableRef?.current?.blur(); + setIsMonthPickerVisible(true); + }} + ref={monthPressableRef} + style={[themeStyles.alignItemsCenter, themeStyles.flexRow]} + wrapperStyle={[themeStyles.alignItemsCenter]} + hoverDimmingValue={1} + testID="currentMonthButton" + accessibilityLabel={`${monthNames.at(currentMonthView)}, ${translate('common.currentMonth')}`} + role={CONST.ROLE.BUTTON} + sentryLabel={CONST.SENTRY_LABEL.CALENDAR_PICKER.MONTH_PICKER} + > + + {monthNames.at(currentMonthView)} + + + + + + + { + pressableRef?.current?.blur(); + setIsYearPickerVisible(true); + }} + ref={pressableRef} + style={[themeStyles.alignItemsCenter, themeStyles.flexRow]} + wrapperStyle={[themeStyles.alignItemsCenter]} + hoverDimmingValue={1} + disabled={years.length <= 1} + testID="currentYearButton" + accessibilityLabel={`${currentYearView}, ${translate('common.currentYear')}`} + role={CONST.ROLE.BUTTON} + sentryLabel={CONST.SENTRY_LABEL.CALENDAR_PICKER.YEAR_PICKER} + > + + {currentYearView} + + + + + + {daysOfWeek.map((dayOfWeek) => ( @@ -325,6 +417,15 @@ function CalendarPicker({ onYearChange={onYearSelected} onClose={() => setIsYearPickerVisible(false)} /> + setIsMonthPickerVisible(false)} + /> ); } diff --git a/src/languages/en.ts b/src/languages/en.ts index 617ca2b93f406..c69d85f509463 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -167,6 +167,10 @@ const translations = { searchWithThreeDots: 'Search...', next: 'Next', previous: 'Previous', + previousMonth: 'Previous month', + nextMonth: 'Next month', + previousYear: 'Previous year', + nextYear: 'Next year', // @context Navigation button that returns the user to the previous screen. Should be interpreted as a UI action label. goBack: 'Go back', create: 'Create', @@ -3212,6 +3216,10 @@ const translations = { year: 'Year', selectYear: 'Please select a year', }, + monthPickerPage: { + month: 'Month', + selectMonth: 'Please select a month', + }, focusModeUpdateModal: { title: 'Welcome to #focus mode!', prompt: (priorityModePageUrl: string) => diff --git a/tests/unit/CalendarPickerTest.tsx b/tests/unit/CalendarPickerTest.tsx index 0b8c3a80ce1a4..15f0822bf50fe 100644 --- a/tests/unit/CalendarPickerTest.tsx +++ b/tests/unit/CalendarPickerTest.tsx @@ -1,6 +1,6 @@ import type * as ReactNavigationNative from '@react-navigation/native'; import {fireEvent, render, screen, userEvent, within} from '@testing-library/react-native'; -import {addMonths, addYears, subMonths, subYears} from 'date-fns'; +import {addMonths, addYears, endOfMonth, startOfMonth, subMonths, subYears} from 'date-fns'; import CalendarPicker from '@components/DatePicker/CalendarPicker'; import DateUtils from '@libs/DateUtils'; @@ -281,4 +281,166 @@ describe('CalendarPicker', () => { // then the label 24 should be clickable expect(screen.getByLabelText('Monday, February 24, 2003')).toBeEnabled(); }); + + test('clicking next year arrow updates the displayed year', () => { + const minDate = new Date('2020-01-01'); + const maxDate = new Date('2030-12-31'); + const value = '2025-06-15'; + render( + , + ); + + fireEvent.press(screen.getByTestId('next-year-arrow')); + + expect(within(screen.getByTestId('currentYearText')).getByText('2026')).toBeTruthy(); + }); + + test('clicking previous year arrow updates the displayed year', () => { + const minDate = new Date('2020-01-01'); + const maxDate = new Date('2030-12-31'); + const value = '2025-06-15'; + render( + , + ); + + fireEvent.press(screen.getByTestId('prev-year-arrow')); + + expect(within(screen.getByTestId('currentYearText')).getByText('2024')).toBeTruthy(); + }); + + test('should block the previous year arrow when there are no available dates in the previous year', async () => { + const minDate = new Date('2023-01-01'); + const value = new Date('2023-06-15'); + render( + , + ); + + const user = userEvent.setup(); + await user.press(screen.getByTestId('prev-year-arrow')); + + // Year should still be 2023 since the button is disabled + expect(within(screen.getByTestId('currentYearText')).getByText('2023')).toBeTruthy(); + }); + + test('should block the next year arrow when there are no available dates in the next year', async () => { + const maxDate = new Date('2023-12-31'); + const value = new Date('2023-06-15'); + render( + , + ); + + const user = userEvent.setup(); + await user.press(screen.getByTestId('next-year-arrow')); + + // Year should still be 2023 since the button is disabled + expect(within(screen.getByTestId('currentYearText')).getByText('2023')).toBeTruthy(); + }); + + test('prev year arrow should clamp to minDate when navigating would go below it', () => { + const minDate = new Date('2023-11-01'); + const maxDate = new Date('2030-12-31'); + const value = '2024-03-15'; + render( + , + ); + + fireEvent.press(screen.getByTestId('prev-year-arrow')); + + // Should clamp to minDate (November 2023), not land on March 2023 + expect(within(screen.getByTestId('currentYearText')).getByText('2023')).toBeTruthy(); + expect(within(screen.getByTestId('currentMonthText')).getByText(monthNames.at(10) ?? '')).toBeTruthy(); + }); + + test('next year arrow should clamp to maxDate when navigating would go above it', () => { + const minDate = new Date('2020-01-01'); + const maxDate = new Date('2025-04-20'); + const value = '2024-09-15'; + render( + , + ); + + fireEvent.press(screen.getByTestId('next-year-arrow')); + + // Should clamp to maxDate (April 2025), not land on September 2025 + expect(within(screen.getByTestId('currentYearText')).getByText('2025')).toBeTruthy(); + expect(within(screen.getByTestId('currentMonthText')).getByText(monthNames.at(3) ?? '')).toBeTruthy(); + }); + + test('month picker filtering should exclude months before minDate', () => { + const currentYear = 2023; + const minDate = new Date('2023-06-01'); + const maxDate = new Date('2030-12-31'); + + const filteredMonths = monthNames + .map((month, index) => { + const monthStart = startOfMonth(new Date(currentYear, index)); + const monthEnd = endOfMonth(new Date(currentYear, index)); + const isBeforeMin = monthEnd < startOfMonth(new Date(minDate)); + const isAfterMax = monthStart > endOfMonth(new Date(maxDate)); + if (isBeforeMin || isAfterMax) { + return null; + } + return {text: month, value: index}; + }) + .filter(Boolean); + + // Months before June (index 5) should be excluded + expect(filteredMonths.find((m) => m?.value === 0)).toBeUndefined(); + expect(filteredMonths.find((m) => m?.value === 4)).toBeUndefined(); + + // June and later months should be included + expect(filteredMonths.find((m) => m?.value === 5)).toBeTruthy(); + expect(filteredMonths.find((m) => m?.value === 11)).toBeTruthy(); + expect(filteredMonths).toHaveLength(7); + }); + + test('month picker filtering should exclude months after maxDate', () => { + const currentYear = 2023; + const minDate = new Date('2020-01-01'); + const maxDate = new Date('2023-09-30'); + + const filteredMonths = monthNames + .map((month, index) => { + const monthStart = startOfMonth(new Date(currentYear, index)); + const monthEnd = endOfMonth(new Date(currentYear, index)); + const isBeforeMin = monthEnd < startOfMonth(new Date(minDate)); + const isAfterMax = monthStart > endOfMonth(new Date(maxDate)); + if (isBeforeMin || isAfterMax) { + return null; + } + return {text: month, value: index}; + }) + .filter(Boolean); + + // Months after September (index 8) should be excluded + expect(filteredMonths.find((m) => m?.value === 10)).toBeUndefined(); + expect(filteredMonths.find((m) => m?.value === 11)).toBeUndefined(); + + // September and earlier months should be included + expect(filteredMonths.find((m) => m?.value === 8)).toBeTruthy(); + expect(filteredMonths.find((m) => m?.value === 0)).toBeTruthy(); + expect(filteredMonths).toHaveLength(9); + }); });