Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
131 changes: 131 additions & 0 deletions src/components/DatePicker/CalendarPicker/MonthPickerModal.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof item> => 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 (
<Modal
type={CONST.MODAL.MODAL_TYPE.RIGHT_DOCKED}
isVisible={isVisible}
onClose={() => onClose?.()}
onModalHide={onClose}
shouldHandleNavigationBack
shouldUseCustomBackdrop
onBackdropPress={onClose}
enableEdgeToEdgeBottomSafeAreaPadding
>
<ScreenWrapper
style={[styles.pb0]}
includePaddingTop={false}
enableEdgeToEdgeBottomSafeAreaPadding
testID="MonthPickerModal"
>
<HeaderWithBackButton
title={translate('monthPickerPage.month')}
onBackButtonPress={onClose}
/>
<SelectionList
data={data}
ListItem={RadioListItem}
onSelectRow={(option) => {
Keyboard.dismiss();
onMonthChange?.(option.value);
}}
textInputOptions={textInputOptions}
initiallyFocusedItemKey={currentMonth.toString()}
disableMaintainingScrollPosition
addBottomSafeAreaPadding
shouldStopPropagation
showScrollIndicator
/>
</ScreenWrapper>
</Modal>
);
}

export default MonthPickerModal;
169 changes: 135 additions & 34 deletions src/components/DatePicker/CalendarPicker/index.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';

Expand Down Expand Up @@ -68,8 +87,10 @@ function CalendarPicker({
const themeStyles = useThemeStyles();
const {translate} = useLocalize();
const pressableRef = useRef<View>(null);
const monthPressableRef = useRef<View>(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();
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -190,44 +240,14 @@ function CalendarPicker({
style={[themeStyles.calendarHeader, themeStyles.flexRow, themeStyles.justifyContentBetween, themeStyles.alignItemsCenter, headerPaddingStyle]}
dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}}
>
<PressableWithFeedback
onPress={() => {
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}
>
<Text
style={themeStyles.sidebarLinkTextBold}
testID="currentYearText"
>
{currentYearView}
</Text>
<ArrowIcon disabled={years.length <= 1} />
</PressableWithFeedback>
<View style={[themeStyles.alignItemsCenter, themeStyles.flexRow, themeStyles.flex1, themeStyles.justifyContentEnd, themeStyles.mrn2]}>
<Text
style={themeStyles.sidebarLinkTextBold}
testID="currentMonthText"
accessibilityLabel={`${monthNames.at(currentMonthView)}, ${translate('common.currentMonth')}`}
>
{monthNames.at(currentMonthView)}
</Text>
<View style={[themeStyles.alignItemsCenter, themeStyles.flexRow, themeStyles.flex1, themeStyles.justifyContentStart]}>
<PressableWithFeedback
shouldUseAutoHitSlop={false}
testID="prev-month-arrow"
disabled={!hasAvailableDatesPrevMonth}
onPress={moveToPrevMonth}
hoverDimmingValue={1}
accessibilityLabel={translate('common.previous')}
accessibilityLabel={translate('common.previousMonth')}
role={CONST.ROLE.BUTTON}
sentryLabel={CONST.SENTRY_LABEL.CALENDAR_PICKER.PREV_MONTH}
>
Expand All @@ -236,19 +256,91 @@ function CalendarPicker({
direction={CONST.DIRECTION.LEFT}
/>
</PressableWithFeedback>
<PressableWithFeedback
onPress={() => {
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}
>
<Text
style={themeStyles.sidebarLinkTextBold}
testID="currentMonthText"
>
{monthNames.at(currentMonthView)}
</Text>
</PressableWithFeedback>
<PressableWithFeedback
shouldUseAutoHitSlop={false}
testID="next-month-arrow"
disabled={!hasAvailableDatesNextMonth}
onPress={moveToNextMonth}
hoverDimmingValue={1}
accessibilityLabel={translate('common.next')}
accessibilityLabel={translate('common.nextMonth')}
role={CONST.ROLE.BUTTON}
sentryLabel={CONST.SENTRY_LABEL.CALENDAR_PICKER.NEXT_MONTH}
>
<ArrowIcon disabled={!hasAvailableDatesNextMonth} />
</PressableWithFeedback>
</View>
<View style={[themeStyles.alignItemsCenter, themeStyles.flexRow, themeStyles.flex1, themeStyles.justifyContentEnd]}>
<PressableWithFeedback
shouldUseAutoHitSlop={false}
testID="prev-year-arrow"
disabled={!hasAvailableDatesPrevYear}
onPress={moveToPrevYear}
hoverDimmingValue={1}
accessibilityLabel={translate('common.previousYear')}
role={CONST.ROLE.BUTTON}
sentryLabel={CONST.SENTRY_LABEL.CALENDAR_PICKER.PREV_YEAR}
>
<ArrowIcon
disabled={!hasAvailableDatesPrevYear}
direction={CONST.DIRECTION.LEFT}
/>
</PressableWithFeedback>
<PressableWithFeedback
onPress={() => {
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}
>
<Text
style={themeStyles.sidebarLinkTextBold}
testID="currentYearText"
>
{currentYearView}
</Text>
</PressableWithFeedback>
<PressableWithFeedback
shouldUseAutoHitSlop={false}
testID="next-year-arrow"
disabled={!hasAvailableDatesNextYear}
onPress={moveToNextYear}
hoverDimmingValue={1}
accessibilityLabel={translate('common.nextYear')}
role={CONST.ROLE.BUTTON}
sentryLabel={CONST.SENTRY_LABEL.CALENDAR_PICKER.NEXT_YEAR}
>
<ArrowIcon disabled={!hasAvailableDatesNextYear} />
</PressableWithFeedback>
</View>
</View>
<View style={[themeStyles.flexRow, webOnlyMarginStyle]}>
{daysOfWeek.map((dayOfWeek) => (
Expand Down Expand Up @@ -325,6 +417,15 @@ function CalendarPicker({
onYearChange={onYearSelected}
onClose={() => setIsYearPickerVisible(false)}
/>
<MonthPickerModal
isVisible={isMonthPickerVisible}
currentMonth={currentMonthView}
currentYear={currentYearView}
minDate={minDate}
maxDate={maxDate}
onMonthChange={onMonthSelected}
onClose={() => setIsMonthPickerVisible(false)}
/>
</View>
);
}
Expand Down
Loading
Loading