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' ;
220import { Str } from 'expensify-common' ;
321import React , { useCallback , useEffect , useRef , useState } from 'react' ;
422import type { StyleProp , ViewStyle } from 'react-native' ;
@@ -15,6 +33,7 @@ import CONST from '@src/CONST';
1533import ArrowIcon from './ArrowIcon' ;
1634import Day from './Day' ;
1735import generateMonthMatrix from './generateMonthMatrix' ;
36+ import MonthPickerModal from './MonthPickerModal' ;
1837import type CalendarPickerListItem from './types' ;
1938import 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