Skip to content

Commit 77d3291

Browse files
authored
Merge pull request Expensify#85107 from mukhrr/fix/83389
2 parents 55e28a4 + 59cfc0d commit 77d3291

15 files changed

Lines changed: 507 additions & 37 deletions

File tree

src/CONST/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9045,8 +9045,11 @@ const CONST = {
90459045
},
90469046
CALENDAR_PICKER: {
90479047
YEAR_PICKER: 'CalendarPicker-YearPicker',
9048+
MONTH_PICKER: 'CalendarPicker-MonthPicker',
90489049
PREV_MONTH: 'CalendarPicker-PrevMonth',
90499050
NEXT_MONTH: 'CalendarPicker-NextMonth',
9051+
PREV_YEAR: 'CalendarPicker-PrevYear',
9052+
NEXT_YEAR: 'CalendarPicker-NextYear',
90509053
DAY: 'CalendarPicker-Day',
90519054
},
90529055
PREV_NEXT_BUTTONS: {
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import React, {useEffect, useMemo, useState} from 'react';
2+
import {Keyboard} from 'react-native';
3+
import HeaderWithBackButton from '@components/HeaderWithBackButton';
4+
import Modal from '@components/Modal';
5+
import ScreenWrapper from '@components/ScreenWrapper';
6+
import SelectionList from '@components/SelectionList';
7+
import RadioListItem from '@components/SelectionList/ListItem/RadioListItem';
8+
import useLocalize from '@hooks/useLocalize';
9+
import useThemeStyles from '@hooks/useThemeStyles';
10+
import DateUtils from '@libs/DateUtils';
11+
import CONST from '@src/CONST';
12+
13+
type MonthPickerModalProps = {
14+
/** Whether the modal is visible */
15+
isVisible: boolean;
16+
17+
/** Currently selected month (0-indexed) */
18+
currentMonth?: number;
19+
20+
/** The year currently being viewed */
21+
currentYear?: number;
22+
23+
/** A minimum date (earliest) allowed to select */
24+
minDate?: Date;
25+
26+
/** A maximum date (latest) allowed to select */
27+
maxDate?: Date;
28+
29+
/** Function to call when the user selects a month */
30+
onMonthChange?: (month: number) => void;
31+
32+
/** Function to call when the user closes the month picker */
33+
onClose?: () => void;
34+
};
35+
36+
function MonthPickerModal({isVisible, currentMonth = new Date().getMonth(), currentYear = new Date().getFullYear(), minDate, maxDate, onMonthChange, onClose}: MonthPickerModalProps) {
37+
const styles = useThemeStyles();
38+
const {translate} = useLocalize();
39+
const [searchText, setSearchText] = useState('');
40+
const monthNames = DateUtils.getMonthNames();
41+
42+
const allMonths = useMemo(() => DateUtils.getFilteredMonthItems(monthNames, currentYear, currentMonth, minDate, maxDate), [monthNames, currentMonth, currentYear, minDate, maxDate]);
43+
44+
const {data, headerMessage} = useMemo(() => {
45+
const filteredMonths = searchText === '' ? allMonths : allMonths.filter((month) => month.text.toLowerCase().includes(searchText.toLowerCase()));
46+
return {
47+
headerMessage: !filteredMonths.length ? translate('common.noResultsFound') : '',
48+
data: filteredMonths,
49+
};
50+
}, [allMonths, searchText, translate]);
51+
52+
useEffect(() => {
53+
if (isVisible) {
54+
return;
55+
}
56+
setSearchText('');
57+
}, [isVisible]);
58+
59+
const textInputOptions = useMemo(
60+
() => ({
61+
label: translate('monthPickerPage.selectMonth'),
62+
value: searchText,
63+
onChangeText: setSearchText,
64+
headerMessage,
65+
}),
66+
[headerMessage, searchText, translate],
67+
);
68+
69+
return (
70+
<Modal
71+
type={CONST.MODAL.MODAL_TYPE.RIGHT_DOCKED}
72+
isVisible={isVisible}
73+
onClose={() => onClose?.()}
74+
onModalHide={onClose}
75+
shouldHandleNavigationBack
76+
shouldUseCustomBackdrop
77+
onBackdropPress={onClose}
78+
enableEdgeToEdgeBottomSafeAreaPadding
79+
>
80+
<ScreenWrapper
81+
style={[styles.pb0]}
82+
includePaddingTop={false}
83+
enableEdgeToEdgeBottomSafeAreaPadding
84+
testID="MonthPickerModal"
85+
>
86+
<HeaderWithBackButton
87+
title={translate('monthPickerPage.month')}
88+
onBackButtonPress={onClose}
89+
/>
90+
<SelectionList
91+
data={data}
92+
ListItem={RadioListItem}
93+
onSelectRow={(option) => {
94+
Keyboard.dismiss();
95+
onMonthChange?.(option.value);
96+
}}
97+
textInputOptions={textInputOptions}
98+
initiallyFocusedItemKey={currentMonth.toString()}
99+
disableMaintainingScrollPosition
100+
addBottomSafeAreaPadding
101+
shouldStopPropagation
102+
showScrollIndicator
103+
/>
104+
</ScreenWrapper>
105+
</Modal>
106+
);
107+
}
108+
109+
export default MonthPickerModal;

src/components/DatePicker/CalendarPicker/index.tsx

Lines changed: 143 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,22 @@
1-
import {addMonths, endOfDay, endOfMonth, format, getYear, isSameDay, parseISO, setDate, setYear, startOfDay, startOfMonth, subMonths} from 'date-fns';
1+
import {
2+
addMonths,
3+
addYears,
4+
endOfDay,
5+
endOfMonth,
6+
endOfYear,
7+
format,
8+
getYear,
9+
isSameDay,
10+
parseISO,
11+
setDate,
12+
setMonth,
13+
setYear,
14+
startOfDay,
15+
startOfMonth,
16+
startOfYear,
17+
subMonths,
18+
subYears,
19+
} from 'date-fns';
220
import {Str} from 'expensify-common';
321
import React, {useCallback, useEffect, useRef, useState} from 'react';
422
import type {StyleProp, ViewStyle} from 'react-native';
@@ -15,6 +33,7 @@ import CONST from '@src/CONST';
1533
import ArrowIcon from './ArrowIcon';
1634
import Day from './Day';
1735
import generateMonthMatrix from './generateMonthMatrix';
36+
import MonthPickerModal from './MonthPickerModal';
1837
import type CalendarPickerListItem from './types';
1938
import YearPickerModal from './YearPickerModal';
2039

@@ -68,8 +87,10 @@ function CalendarPicker({
6887
const themeStyles = useThemeStyles();
6988
const {translate} = useLocalize();
7089
const pressableRef = useRef<View>(null);
90+
const monthPressableRef = useRef<View>(null);
7191
const [currentDateView, setCurrentDateView] = useState(() => getInitialCurrentDateView(value, minDate, maxDate));
7292
const [isYearPickerVisible, setIsYearPickerVisible] = useState(false);
93+
const [isMonthPickerVisible, setIsMonthPickerVisible] = useState(false);
7394
const isFirstRender = useRef(true);
7495

7596
const currentMonthView = currentDateView.getMonth();
@@ -104,6 +125,11 @@ function CalendarPicker({
104125
requestAnimationFrame(() => setIsYearPickerVisible(false));
105126
};
106127

128+
const onMonthSelected = (month: number) => {
129+
setCurrentDateView((prev) => setMonth(new Date(prev), month));
130+
requestAnimationFrame(() => setIsMonthPickerVisible(false));
131+
};
132+
107133
/**
108134
* Calls the onSelected function with the selected date.
109135
* @param day - The day of the month that was selected.
@@ -153,10 +179,34 @@ function CalendarPicker({
153179
});
154180
};
155181

182+
const moveToPrevYear = () => {
183+
setCurrentDateView((prev) => {
184+
let prevYear = subYears(new Date(prev), 1);
185+
if (prevYear < new Date(minDate)) {
186+
prevYear = new Date(minDate);
187+
}
188+
setYears((prevYears) => prevYears.map((item) => ({...item, isSelected: item.value === prevYear.getFullYear()})));
189+
return prevYear;
190+
});
191+
};
192+
193+
const moveToNextYear = () => {
194+
setCurrentDateView((prev) => {
195+
let nextYear = addYears(new Date(prev), 1);
196+
if (nextYear > new Date(maxDate)) {
197+
nextYear = new Date(maxDate);
198+
}
199+
setYears((prevYears) => prevYears.map((item) => ({...item, isSelected: item.value === nextYear.getFullYear()})));
200+
return nextYear;
201+
});
202+
};
203+
156204
const monthNames = DateUtils.getMonthNames().map((month) => Str.UCFirst(month));
157205
const daysOfWeek = DateUtils.getDaysOfWeek().map((day) => day.toUpperCase());
158206
const hasAvailableDatesNextMonth = startOfDay(new Date(maxDate)) > endOfMonth(new Date(currentDateView));
159207
const hasAvailableDatesPrevMonth = endOfDay(new Date(minDate)) < startOfMonth(new Date(currentDateView));
208+
const hasAvailableDatesNextYear = startOfDay(new Date(maxDate)) > endOfYear(new Date(currentDateView));
209+
const hasAvailableDatesPrevYear = endOfDay(new Date(minDate)) < startOfYear(new Date(currentDateView));
160210

161211
useEffect(() => {
162212
if (isSmallScreenWidth || isFirstRender.current) {
@@ -178,7 +228,7 @@ function CalendarPicker({
178228

179229
const webOnlyMarginStyle = isSmallScreenWidth ? {} : styles.mh1;
180230
const calendarContainerStyle = isSmallScreenWidth ? [webOnlyMarginStyle, themeStyles.calendarBodyContainer] : [webOnlyMarginStyle, animatedStyle];
181-
const headerPaddingStyle = headerContainerStyle ?? themeStyles.ph5;
231+
const headerPaddingStyle = headerContainerStyle ?? themeStyles.ph3;
182232
// On mobile (isSmallScreenWidth is always true on native), the height animation is skipped
183233
// so using Animated.View is unnecessary. Using a plain View with collapsable={false} avoids
184234
// activating Reanimated's Fabric commit hook, which on Android can interfere with React's
@@ -188,49 +238,19 @@ function CalendarPicker({
188238
const getAccessibilityState = useCallback((isSelected: boolean) => ({selected: isSelected}), []);
189239

190240
return (
191-
<View style={[themeStyles.pb4]}>
241+
<View style={[themeStyles.pb4, themeStyles.pt1]}>
192242
<View
193-
style={[themeStyles.calendarHeader, themeStyles.flexRow, themeStyles.justifyContentBetween, themeStyles.alignItemsCenter, headerPaddingStyle]}
243+
style={[themeStyles.calendarHeader, themeStyles.flexRow, themeStyles.justifyContentBetween, themeStyles.alignItemsCenter, themeStyles.gap3, headerPaddingStyle]}
194244
dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}}
195245
>
196-
<PressableWithFeedback
197-
onPress={() => {
198-
pressableRef?.current?.blur();
199-
setIsYearPickerVisible(true);
200-
}}
201-
ref={pressableRef}
202-
style={[themeStyles.alignItemsCenter, themeStyles.flexRow, themeStyles.flex1, themeStyles.justifyContentStart]}
203-
wrapperStyle={[themeStyles.alignItemsCenter]}
204-
hoverDimmingValue={1}
205-
disabled={years.length <= 1}
206-
testID="currentYearButton"
207-
accessibilityLabel={`${currentYearView}, ${translate('common.currentYear')}`}
208-
role={CONST.ROLE.BUTTON}
209-
sentryLabel={CONST.SENTRY_LABEL.CALENDAR_PICKER.YEAR_PICKER}
210-
>
211-
<Text
212-
style={themeStyles.sidebarLinkTextBold}
213-
testID="currentYearText"
214-
>
215-
{currentYearView}
216-
</Text>
217-
<ArrowIcon disabled={years.length <= 1} />
218-
</PressableWithFeedback>
219-
<View style={[themeStyles.alignItemsCenter, themeStyles.flexRow, themeStyles.flex1, themeStyles.justifyContentEnd, themeStyles.mrn2]}>
220-
<Text
221-
style={themeStyles.sidebarLinkTextBold}
222-
testID="currentMonthText"
223-
accessibilityLabel={`${monthNames.at(currentMonthView)}, ${translate('common.currentMonth')}`}
224-
>
225-
{monthNames.at(currentMonthView)}
226-
</Text>
246+
<View style={[themeStyles.alignItemsCenter, themeStyles.flexRow, {flex: 3}]}>
227247
<PressableWithFeedback
228248
shouldUseAutoHitSlop={false}
229249
testID="prev-month-arrow"
230250
disabled={!hasAvailableDatesPrevMonth}
231251
onPress={moveToPrevMonth}
232252
hoverDimmingValue={1}
233-
accessibilityLabel={translate('common.previous')}
253+
accessibilityLabel={translate('common.previousMonth')}
234254
role={CONST.ROLE.BUTTON}
235255
sentryLabel={CONST.SENTRY_LABEL.CALENDAR_PICKER.PREV_MONTH}
236256
>
@@ -239,19 +259,96 @@ function CalendarPicker({
239259
direction={CONST.DIRECTION.LEFT}
240260
/>
241261
</PressableWithFeedback>
262+
<View style={[themeStyles.flex1, themeStyles.alignItemsCenter]}>
263+
<PressableWithFeedback
264+
onPress={() => {
265+
monthPressableRef?.current?.blur();
266+
setIsMonthPickerVisible(true);
267+
}}
268+
ref={monthPressableRef}
269+
style={[themeStyles.alignItemsCenter]}
270+
wrapperStyle={[themeStyles.alignItemsCenter]}
271+
hoverDimmingValue={1}
272+
testID="currentMonthButton"
273+
accessibilityLabel={`${monthNames.at(currentMonthView)}, ${translate('common.currentMonth')}`}
274+
role={CONST.ROLE.BUTTON}
275+
sentryLabel={CONST.SENTRY_LABEL.CALENDAR_PICKER.MONTH_PICKER}
276+
>
277+
<Text
278+
style={themeStyles.sidebarLinkTextBold}
279+
testID="currentMonthText"
280+
numberOfLines={1}
281+
>
282+
{monthNames.at(currentMonthView)}
283+
</Text>
284+
</PressableWithFeedback>
285+
</View>
242286
<PressableWithFeedback
243287
shouldUseAutoHitSlop={false}
244288
testID="next-month-arrow"
245289
disabled={!hasAvailableDatesNextMonth}
246290
onPress={moveToNextMonth}
247291
hoverDimmingValue={1}
248-
accessibilityLabel={translate('common.next')}
292+
accessibilityLabel={translate('common.nextMonth')}
249293
role={CONST.ROLE.BUTTON}
250294
sentryLabel={CONST.SENTRY_LABEL.CALENDAR_PICKER.NEXT_MONTH}
251295
>
252296
<ArrowIcon disabled={!hasAvailableDatesNextMonth} />
253297
</PressableWithFeedback>
254298
</View>
299+
<View style={[themeStyles.alignItemsCenter, themeStyles.flexRow, {flex: 2}]}>
300+
<PressableWithFeedback
301+
shouldUseAutoHitSlop={false}
302+
testID="prev-year-arrow"
303+
disabled={!hasAvailableDatesPrevYear}
304+
onPress={moveToPrevYear}
305+
hoverDimmingValue={1}
306+
accessibilityLabel={translate('common.previousYear')}
307+
role={CONST.ROLE.BUTTON}
308+
sentryLabel={CONST.SENTRY_LABEL.CALENDAR_PICKER.PREV_YEAR}
309+
>
310+
<ArrowIcon
311+
disabled={!hasAvailableDatesPrevYear}
312+
direction={CONST.DIRECTION.LEFT}
313+
/>
314+
</PressableWithFeedback>
315+
<View style={[themeStyles.flex1, themeStyles.alignItemsCenter]}>
316+
<PressableWithFeedback
317+
onPress={() => {
318+
pressableRef?.current?.blur();
319+
setIsYearPickerVisible(true);
320+
}}
321+
ref={pressableRef}
322+
style={[themeStyles.alignItemsCenter]}
323+
wrapperStyle={[themeStyles.alignItemsCenter]}
324+
hoverDimmingValue={1}
325+
disabled={years.length <= 1}
326+
testID="currentYearButton"
327+
accessibilityLabel={`${currentYearView}, ${translate('common.currentYear')}`}
328+
role={CONST.ROLE.BUTTON}
329+
sentryLabel={CONST.SENTRY_LABEL.CALENDAR_PICKER.YEAR_PICKER}
330+
>
331+
<Text
332+
style={themeStyles.sidebarLinkTextBold}
333+
testID="currentYearText"
334+
>
335+
{currentYearView}
336+
</Text>
337+
</PressableWithFeedback>
338+
</View>
339+
<PressableWithFeedback
340+
shouldUseAutoHitSlop={false}
341+
testID="next-year-arrow"
342+
disabled={!hasAvailableDatesNextYear}
343+
onPress={moveToNextYear}
344+
hoverDimmingValue={1}
345+
accessibilityLabel={translate('common.nextYear')}
346+
role={CONST.ROLE.BUTTON}
347+
sentryLabel={CONST.SENTRY_LABEL.CALENDAR_PICKER.NEXT_YEAR}
348+
>
349+
<ArrowIcon disabled={!hasAvailableDatesNextYear} />
350+
</PressableWithFeedback>
351+
</View>
255352
</View>
256353
<View style={[themeStyles.flexRow, webOnlyMarginStyle]}>
257354
{daysOfWeek.map((dayOfWeek) => (
@@ -332,6 +429,15 @@ function CalendarPicker({
332429
onYearChange={onYearSelected}
333430
onClose={() => setIsYearPickerVisible(false)}
334431
/>
432+
<MonthPickerModal
433+
isVisible={isMonthPickerVisible}
434+
currentMonth={currentMonthView}
435+
currentYear={currentYearView}
436+
minDate={minDate}
437+
maxDate={maxDate}
438+
onMonthChange={onMonthSelected}
439+
onClose={() => setIsMonthPickerVisible(false)}
440+
/>
335441
</View>
336442
);
337443
}

0 commit comments

Comments
 (0)