From 1a5dac98d109c1ed8170ce9f24396da18a993b92 Mon Sep 17 00:00:00 2001 From: Tom Date: Tue, 19 May 2026 10:37:48 -0700 Subject: [PATCH 01/22] Migrate YearPickerModal to a @react-navigation modal screen Replace the react-native-modal based YearPickerModal, rendered inside CalendarPicker, with a dynamic @react-navigation route so it animates consistently with the surrounding native stack. - Add the DYNAMIC_YEAR_SELECTOR dynamic route and the standalone DynamicYearSelectorPage screen (year list + search), built from route query params. - Lift the picker result out of CalendarPicker local state into a transient Onyx key, tagged with a per-instance contextID so the correct CalendarPicker consumes it (range pickers mount two). - CalendarPicker navigates to the year route and applies the returned year; hosts that render it inside a popover pass onBeforeOpenYearPicker to dismiss the popover before the year screen is shown. - Remove YearPickerModal and the now-unused year list state. - Update CalendarPicker tests for the navigation flow and add coverage for the year selection action. --- src/ONYXKEYS.ts | 4 + src/ROUTES.ts | 17 +++ src/SCREENS.ts | 1 + .../DynamicYearSelectorPage.tsx | 94 ++++++++++++++++ .../CalendarPicker/YearPickerModal.tsx | 105 ------------------ .../DatePicker/CalendarPicker/index.tsx | 101 ++++++++--------- src/components/DatePicker/DatePickerModal.tsx | 2 + .../FilterComponents/DatePresetFilterBase.tsx | 7 ++ .../FilterComponents/RangeDatePicker.tsx | 9 +- .../FilterDropdowns/DateSelectPopup/index.tsx | 1 + .../ModalStackNavigators/index.tsx | 1 + src/libs/Navigation/linkingConfig/config.ts | 1 + src/libs/Navigation/types.ts | 13 +++ src/libs/actions/CalendarPicker.ts | 17 +++ src/pages/ScheduleCall/ScheduleCallPage.tsx | 1 + tests/actions/CalendarPickerTest.ts | 37 ++++++ tests/unit/CalendarPickerTest.tsx | 75 ++++--------- 17 files changed, 274 insertions(+), 212 deletions(-) create mode 100644 src/components/DatePicker/CalendarPicker/DynamicYearSelectorPage.tsx delete mode 100644 src/components/DatePicker/CalendarPicker/YearPickerModal.tsx create mode 100644 src/libs/actions/CalendarPicker.ts create mode 100644 tests/actions/CalendarPickerTest.ts diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 24ec39448a75..196153bf9daf 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -591,6 +591,9 @@ const ONYXKEYS = { /** Stores the role selected for members being imported from a spreadsheet */ IMPORTED_SPREADSHEET_MEMBER_ROLE: 'importedSpreadsheetMemberRole', + /** Stores the year selected in the year picker so it can be read back by the CalendarPicker that opened it */ + CALENDAR_PICKER_SELECTED_YEAR: 'calendarPickerSelectedYear', + /** Stores the route to open after changing app permission from settings */ LAST_ROUTE: 'lastRoute', @@ -1575,6 +1578,7 @@ type OnyxValuesMapping = { [ONYXKEYS.IMPORTED_SPREADSHEET]: OnyxTypes.ImportedSpreadsheet; [ONYXKEYS.IMPORTED_SPREADSHEET_MEMBER_DATA]: OnyxTypes.ImportedSpreadsheetMemberData[]; [ONYXKEYS.IMPORTED_SPREADSHEET_MEMBER_ROLE]: ValueOf; + [ONYXKEYS.CALENDAR_PICKER_SELECTED_YEAR]: {contextID: string; year: number}; [ONYXKEYS.LAST_ROUTE]: string; [ONYXKEYS.IS_USING_IMPORTED_STATE]: boolean; [ONYXKEYS.NVP_EXPENSIFY_COMPANY_CARDS_CUSTOM_NAMES]: Record; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index f6f0d34b6ae5..cbd4fc045f56 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -108,6 +108,23 @@ const DYNAMIC_ROUTES = { path: 'imported-members-role', entryScreens: [SCREENS.WORKSPACE.MEMBERS_IMPORTED_CONFIRMATION], }, + YEAR_SELECTOR: { + path: 'year-selector', + queryParams: ['contextID', 'currentYear', 'minYear', 'maxYear'], + entryScreens: [ + SCREENS.SETTINGS.PROFILE.DATE_OF_BIRTH, + SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_DATE, + SCREENS.MONEY_REQUEST.STEP_DATE, + SCREENS.MONEY_REQUEST.STEP_TIME, + SCREENS.MONEY_REQUEST.STEP_TIME_EDIT, + SCREENS.MONEY_REQUEST.SPLIT_EXPENSE_CREATE_DATE_RANGE, + SCREENS.SEARCH.ROOT, + SCREENS.SEARCH.ADVANCED_FILTERS_DATE_RHP, + SCREENS.SEARCH.EDIT_MULTIPLE_DATE_RHP, + SCREENS.CHRONOS_SCHEDULE_OOO_ROOT, + SCREENS.SCHEDULE_CALL, + ], + }, REPORT_SETTINGS_NAME: { path: 'settings/name', entryScreens: [SCREENS.REPORT_DETAILS.ROOT], diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 16f9aaf4beef..9a4e4f734074 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -136,6 +136,7 @@ const SCREENS = { HELP: 'Settings_Help', DYNAMIC_VERIFY_ACCOUNT: 'Dynamic_Verify_Account', DYNAMIC_ADD_BANK_ACCOUNT_VERIFY_ACCOUNT: 'Dynamic_Add_Bank_Account_Verify_Account', + DYNAMIC_YEAR_SELECTOR: 'Dynamic_Year_Selector', DYNAMIC_EXIT_SURVEY_CONFIRM: 'Dynamic_ExitSurvey_Confirm', DYNAMIC_EXIT_SURVEY_REASON: 'Dynamic_ExitSurvey_Reason', DYNAMIC_KEYBOARD_SHORTCUTS: 'Dynamic_Keyboard_Shortcuts', diff --git a/src/components/DatePicker/CalendarPicker/DynamicYearSelectorPage.tsx b/src/components/DatePicker/CalendarPicker/DynamicYearSelectorPage.tsx new file mode 100644 index 000000000000..79007cc00466 --- /dev/null +++ b/src/components/DatePicker/CalendarPicker/DynamicYearSelectorPage.tsx @@ -0,0 +1,94 @@ +import React, {useMemo, useState} from 'react'; +import {Keyboard} from 'react-native'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import SelectionList from '@components/SelectionList'; +import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; +import useDynamicBackPath from '@hooks/useDynamicBackPath'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {setCalendarPickerSelectedYear} from '@libs/actions/CalendarPicker'; +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@navigation/types'; +import CONST from '@src/CONST'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import type CalendarPickerListItem from './types'; + +type DynamicYearSelectorPageProps = PlatformStackScreenProps; + +function DynamicYearSelectorPage({route}: DynamicYearSelectorPageProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const backPath = useDynamicBackPath(DYNAMIC_ROUTES.YEAR_SELECTOR.path); + + const {contextID} = route.params; + const currentYear = Number(route.params.currentYear) || new Date().getFullYear(); + const minYear = Number(route.params.minYear) || CONST.CALENDAR_PICKER.MIN_YEAR; + const maxYear = Number(route.params.maxYear) || CONST.CALENDAR_PICKER.MAX_YEAR; + + const [searchText, setSearchText] = useState(''); + + const years: CalendarPickerListItem[] = useMemo( + () => + Array.from({length: maxYear - minYear + 1}, (value, index) => index + minYear).map((year) => ({ + text: year.toString(), + value: year, + keyForList: year.toString(), + isSelected: year === currentYear, + })), + [minYear, maxYear, currentYear], + ); + + const {data, headerMessage} = useMemo(() => { + const yearsList = searchText === '' ? years : years.filter((year) => year.text?.includes(searchText)); + return { + headerMessage: !yearsList.length ? translate('common.noResultsFound') : '', + data: yearsList.sort((a, b) => b.value - a.value), + }; + }, [years, searchText, translate]); + + const textInputOptions = useMemo( + () => ({ + label: translate('yearPickerPage.selectYear'), + value: searchText, + onChangeText: (text: string) => setSearchText(text.replaceAll(CONST.REGEX.NON_NUMERIC, '').trim()), + headerMessage, + maxLength: 4, + inputMode: CONST.INPUT_MODE.NUMERIC, + }), + [headerMessage, searchText, translate], + ); + + return ( + + Navigation.goBack(backPath)} + /> + { + Keyboard.dismiss(); + setCalendarPickerSelectedYear(contextID, option.value); + Navigation.goBack(backPath); + }} + textInputOptions={textInputOptions} + initiallyFocusedItemKey={currentYear.toString()} + disableMaintainingScrollPosition + addBottomSafeAreaPadding + shouldStopPropagation + showScrollIndicator + /> + + ); +} + +export default DynamicYearSelectorPage; diff --git a/src/components/DatePicker/CalendarPicker/YearPickerModal.tsx b/src/components/DatePicker/CalendarPicker/YearPickerModal.tsx deleted file mode 100644 index dd685b5d1494..000000000000 --- a/src/components/DatePicker/CalendarPicker/YearPickerModal.tsx +++ /dev/null @@ -1,105 +0,0 @@ -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 SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; -import useLocalize from '@hooks/useLocalize'; -import useThemeStyles from '@hooks/useThemeStyles'; -import CONST from '@src/CONST'; -import type CalendarPickerListItem from './types'; - -type YearPickerModalProps = { - /** Whether the modal is visible */ - isVisible: boolean; - - /** The list of years to render */ - years: CalendarPickerListItem[]; - - /** Currently selected year */ - currentYear?: number; - - /** Function to call when the user selects a year */ - onYearChange?: (year: number) => void; - - /** Function to call when the user closes the year picker */ - onClose?: () => void; - - /** Whether RIGHT_DOCKED modal should keep backdrop in narrow pane context */ - shouldEnableBackdropInNarrowPane?: boolean; -}; - -function YearPickerModal({isVisible, years, currentYear = new Date().getFullYear(), onYearChange, onClose, shouldEnableBackdropInNarrowPane = false}: YearPickerModalProps) { - const styles = useThemeStyles(); - const {translate} = useLocalize(); - const [searchText, setSearchText] = useState(''); - const {data, headerMessage} = useMemo(() => { - const yearsList = searchText === '' ? years : years.filter((year) => year.text?.includes(searchText)); - return { - headerMessage: !yearsList.length ? translate('common.noResultsFound') : '', - data: yearsList.sort((a, b) => b.value - a.value), - }; - }, [years, searchText, translate]); - - useEffect(() => { - if (isVisible) { - return; - } - setSearchText(''); - }, [isVisible]); - - const textInputOptions = useMemo( - () => ({ - label: translate('yearPickerPage.selectYear'), - value: searchText, - onChangeText: (text: string) => setSearchText(text.replaceAll(CONST.REGEX.NON_NUMERIC, '').trim()), - headerMessage, - maxLength: 4, - inputMode: CONST.INPUT_MODE.NUMERIC, - }), - [headerMessage, searchText, translate], - ); - - return ( - onClose?.()} - onModalHide={onClose} - shouldHandleNavigationBack - shouldUseCustomBackdrop - onBackdropPress={onClose} - shouldKeepRightDockedBackdropInNarrowPane={shouldEnableBackdropInNarrowPane} - enableEdgeToEdgeBottomSafeAreaPadding - > - - - { - Keyboard.dismiss(); - onYearChange?.(option.value); - }} - textInputOptions={textInputOptions} - initiallyFocusedItemKey={currentYear.toString()} - disableMaintainingScrollPosition - addBottomSafeAreaPadding - shouldStopPropagation - showScrollIndicator - /> - - - ); -} - -export default YearPickerModal; diff --git a/src/components/DatePicker/CalendarPicker/index.tsx b/src/components/DatePicker/CalendarPicker/index.tsx index 2b34d6d559b6..cb189ac54e04 100644 --- a/src/components/DatePicker/CalendarPicker/index.tsx +++ b/src/components/DatePicker/CalendarPicker/index.tsx @@ -8,16 +8,20 @@ import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; +import {clearCalendarPickerSelectedYear} from '@libs/actions/CalendarPicker'; import DateUtils from '@libs/DateUtils'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; +import Navigation from '@libs/Navigation/Navigation'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; 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'; type CalendarPickerProps = { /** An initial value of date string */ @@ -41,8 +45,23 @@ type CalendarPickerProps = { /** Optional style override for the header container */ headerContainerStyle?: StyleProp; - /** Whether Month/Year right-docked picker modals should keep backdrop in narrow pane context */ + /** Whether Month right-docked picker modal should keep backdrop in narrow pane context */ shouldEnableMonthYearBackdropInNarrowPane?: boolean; + + /** + * Stable identifier used to match the year selected on the year picker screen back to this + * CalendarPicker instance. Hosts that render CalendarPicker inside a popover/modal that is + * dismissed when navigating (so this component unmounts) must pass a stable id; hosts that + * mount more than one CalendarPicker (e.g. range pickers) must pass distinct ids. + */ + pickerContextID?: string; + + /** + * Called right before navigating to the year picker screen. Hosts that render CalendarPicker + * inside a popover/modal use this to dismiss it first, so the year picker screen is not + * rendered behind it. + */ + onBeforeOpenYearPicker?: () => void; }; function getInitialCurrentDateView(value: Date | string, minDate: Date, maxDate: Date) { @@ -75,6 +94,8 @@ function CalendarPicker({ selectableDates, headerContainerStyle, shouldEnableMonthYearBackdropInNarrowPane = false, + pickerContextID, + onBeforeOpenYearPicker, }: CalendarPickerProps) { // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth} = useResponsiveLayout(); @@ -84,8 +105,10 @@ function CalendarPicker({ 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 [fallbackContextID] = useState(() => `calendarPicker-${Math.random().toString(36).slice(2)}`); + const contextID = pickerContextID ?? fallbackContextID; + const [selectedYearResult] = useOnyx(ONYXKEYS.CALENDAR_PICKER_SELECTED_YEAR); const isFirstRender = useRef(true); const currentMonthView = currentDateView.getMonth(); @@ -97,27 +120,24 @@ function CalendarPicker({ const minYear = CONST.CALENDAR_PICKER.MIN_YEAR; const maxYear = CONST.CALENDAR_PICKER.MAX_YEAR; - const [years, setYears] = useState(() => - Array.from({length: maxYear - minYear + 1}, (v, i) => i + minYear).map((year) => ({ - text: year.toString(), - value: year, - keyForList: year.toString(), - isSelected: year === currentDateView.getFullYear(), - })), - ); - - const onYearSelected = (year: number) => { - setCurrentDateView((prev) => { - const newCurrentDateView = setYear(new Date(prev), year); - setYears((prevYears) => - prevYears.map((item) => ({ - ...item, - isSelected: item.value === newCurrentDateView.getFullYear(), - })), - ); - return newCurrentDateView; - }); - requestAnimationFrame(() => setIsYearPickerVisible(false)); + // When the year picker screen writes back a selection for this CalendarPicker instance, + // apply it to the displayed date and clear the transient result so it isn't re-applied. + useEffect(() => { + if (!selectedYearResult || selectedYearResult.contextID !== contextID) { + return; + } + const {year} = selectedYearResult; + clearCalendarPickerSelectedYear(); + requestAnimationFrame(() => setCurrentDateView((prev) => setYear(new Date(prev), year))); + }, [selectedYearResult, contextID]); + + const openYearPicker = () => { + onBeforeOpenYearPicker?.(); + Navigation.navigate( + createDynamicRoute( + `${DYNAMIC_ROUTES.YEAR_SELECTOR.path}?contextID=${encodeURIComponent(contextID)}¤tYear=${currentYearView}&minYear=${minYear}&maxYear=${maxYear}`, + ), + ); }; const onMonthSelected = (month: number) => { @@ -149,15 +169,6 @@ function CalendarPicker({ if (prevMonth.getFullYear() < CONST.CALENDAR_PICKER.MIN_YEAR) { return prev; } - // if year is subtracted, we need to update the years list - if (prevMonth.getFullYear() < prev.getFullYear()) { - setYears((prevYears) => - prevYears.map((item) => ({ - ...item, - isSelected: item.value === prevMonth.getFullYear(), - })), - ); - } return prevMonth; }); }; @@ -171,16 +182,6 @@ function CalendarPicker({ if (nextMonth.getFullYear() > CONST.CALENDAR_PICKER.MAX_YEAR) { return prev; } - // if year is added, we need to update the years list - if (nextMonth.getFullYear() > prev.getFullYear()) { - setYears((prevYears) => - prevYears.map((item) => ({ - ...item, - isSelected: item.value === nextMonth.getFullYear(), - })), - ); - } - return nextMonth; }); }; @@ -191,7 +192,6 @@ function CalendarPicker({ if (prevYear.getFullYear() < CONST.CALENDAR_PICKER.MIN_YEAR) { return prev; } - setYears((prevYears) => prevYears.map((item) => ({...item, isSelected: item.value === prevYear.getFullYear()}))); return prevYear; }); }; @@ -202,7 +202,6 @@ function CalendarPicker({ if (nextYear.getFullYear() > CONST.CALENDAR_PICKER.MAX_YEAR) { return prev; } - setYears((prevYears) => prevYears.map((item) => ({...item, isSelected: item.value === nextYear.getFullYear()}))); return nextYear; }); }; @@ -317,7 +316,7 @@ function CalendarPicker({ { pressableRef?.current?.blur(); - setIsYearPickerVisible(true); + openYearPicker(); }} ref={pressableRef} style={[themeStyles.alignItemsCenter]} @@ -422,14 +421,6 @@ function CalendarPicker({ ))} - setIsYearPickerVisible(false)} - shouldEnableBackdropInNarrowPane={shouldEnableMonthYearBackdropInNarrowPane} - /> ); diff --git a/src/components/Search/FilterComponents/DatePresetFilterBase.tsx b/src/components/Search/FilterComponents/DatePresetFilterBase.tsx index 5530b94fa84f..095df8863035 100644 --- a/src/components/Search/FilterComponents/DatePresetFilterBase.tsx +++ b/src/components/Search/FilterComponents/DatePresetFilterBase.tsx @@ -103,6 +103,9 @@ type DatePresetFilterBaseProps = { /** Force vertical stacking of calendars in range picker */ forceVerticalCalendars?: boolean; + /** Called right before navigating to the year picker screen (used by popover hosts to dismiss themselves) */ + onBeforeOpenYearPicker?: () => void; + /** The ref handle */ ref: Ref; }; @@ -123,6 +126,7 @@ function DatePresetFilterBase({ onDateValuesChange, onRangeValidationErrorChange, forceVerticalCalendars = false, + onBeforeOpenYearPicker, ref, }: DatePresetFilterBaseProps) { const theme = useTheme(); @@ -456,6 +460,7 @@ function DatePresetFilterBase({ onRangeValidationErrorChange?.(false); }} forceVertical={forceVerticalCalendars} + onBeforeOpenYearPicker={onBeforeOpenYearPicker} /> ); } @@ -467,6 +472,8 @@ function DatePresetFilterBase({ onSelected={handleSingleDateSelected} minDate={CONST.CALENDAR_PICKER.MIN_DATE} maxDate={CONST.CALENDAR_PICKER.MAX_DATE} + pickerContextID="searchSingleDate" + onBeforeOpenYearPicker={onBeforeOpenYearPicker} /> void; }; function parseCalendarDate(dateValue?: string): Date | undefined { @@ -34,7 +37,7 @@ function parseCalendarDate(dateValue?: string): Date | undefined { return isValid(parsedDate) ? parsedDate : undefined; } -function RangeDatePicker({fromValue, toValue, onFromSelected, onToSelected, forceVertical = false}: RangeDatePickerProps) { +function RangeDatePicker({fromValue, toValue, onFromSelected, onToSelected, forceVertical = false, onBeforeOpenYearPicker}: RangeDatePickerProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth @@ -54,6 +57,8 @@ function RangeDatePicker({fromValue, toValue, onFromSelected, onToSelected, forc minDate={CONST.CALENDAR_PICKER.MIN_DATE} maxDate={fromMaxDate} headerContainerStyle={styles.ph4} + pickerContextID="searchRangeFrom" + onBeforeOpenYearPicker={onBeforeOpenYearPicker} /> @@ -67,6 +72,8 @@ function RangeDatePicker({fromValue, toValue, onFromSelected, onToSelected, forc minDate={toMinDate} maxDate={CONST.CALENDAR_PICKER.MAX_DATE} headerContainerStyle={styles.ph4} + pickerContextID="searchRangeTo" + onBeforeOpenYearPicker={onBeforeOpenYearPicker} /> diff --git a/src/components/Search/FilterDropdowns/DateSelectPopup/index.tsx b/src/components/Search/FilterDropdowns/DateSelectPopup/index.tsx index e220db8f883d..ea96013de643 100644 --- a/src/components/Search/FilterDropdowns/DateSelectPopup/index.tsx +++ b/src/components/Search/FilterDropdowns/DateSelectPopup/index.tsx @@ -154,6 +154,7 @@ function DateSelectPopup({label, value, presets, style, closeOverlay, onChange, presets={presets} onDateValuesChange={updateRangeText} onRangeValidationErrorChange={setShouldShowRangeError} + onBeforeOpenYearPicker={closeOverlay} /> {shouldShowRangeError && ( require('../../../../pages/settings/Wallet/DynamicAddBankAccountVerifyAccountPage').default, ), + [SCREENS.SETTINGS.DYNAMIC_YEAR_SELECTOR]: () => require('../../../../components/DatePicker/CalendarPicker/DynamicYearSelectorPage').default, [SCREENS.SETTINGS.SHARE_CODE]: () => require('../../../../pages/ShareCodePage').default, [SCREENS.SETTINGS.PROFILE.PRONOUNS]: withAgentAccessDenied(() => require('../../../../pages/settings/Profile/PronounsPage').default), [SCREENS.SETTINGS.PROFILE.DISPLAY_NAME]: () => require('../../../../pages/settings/Profile/DisplayNamePage').default, diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 4328017fda28..045a06cdd874 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -468,6 +468,7 @@ const config: LinkingOptions['config'] = { }, [SCREENS.SETTINGS.DYNAMIC_VERIFY_ACCOUNT]: DYNAMIC_ROUTES.VERIFY_ACCOUNT.path, [SCREENS.SETTINGS.DYNAMIC_ADD_BANK_ACCOUNT_VERIFY_ACCOUNT]: DYNAMIC_ROUTES.ADD_BANK_ACCOUNT_VERIFY_ACCOUNT.path, + [SCREENS.SETTINGS.DYNAMIC_YEAR_SELECTOR]: DYNAMIC_ROUTES.YEAR_SELECTOR.path, [SCREENS.SETTINGS.PROFILE.CONTACT_METHODS]: { path: ROUTES.SETTINGS_CONTACT_METHODS.route, exact: true, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index e36072ac31fc..352505d89adc 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -141,6 +141,19 @@ type SettingsNavigatorParamList = { [SCREENS.SETTINGS.LOCK.FAILED_TO_LOCK_ACCOUNT]: undefined; [SCREENS.SETTINGS.DYNAMIC_VERIFY_ACCOUNT]: undefined; [SCREENS.SETTINGS.DYNAMIC_ADD_BANK_ACCOUNT_VERIFY_ACCOUNT]: undefined; + [SCREENS.SETTINGS.DYNAMIC_YEAR_SELECTOR]: { + /** Stable id of the CalendarPicker instance that opened the year picker */ + contextID: string; + + /** Currently displayed year */ + currentYear: string; + + /** Minimum selectable year */ + minYear: string; + + /** Maximum selectable year */ + maxYear: string; + }; [SCREENS.SETTINGS.DYNAMIC_EXIT_SURVEY_REASON]: undefined; [SCREENS.SETTINGS.DYNAMIC_EXIT_SURVEY_CONFIRM]: undefined; [SCREENS.SETTINGS.WALLET.CARDS_DIGITAL_DETAILS_UPDATE_ADDRESS]: undefined; diff --git a/src/libs/actions/CalendarPicker.ts b/src/libs/actions/CalendarPicker.ts new file mode 100644 index 000000000000..11dc03bbd751 --- /dev/null +++ b/src/libs/actions/CalendarPicker.ts @@ -0,0 +1,17 @@ +import Onyx from 'react-native-onyx'; +import ONYXKEYS from '@src/ONYXKEYS'; + +/** + * Stores the year selected in the year picker screen so the CalendarPicker instance that + * opened it (identified by `contextID`) can read it back when it is next rendered. + */ +function setCalendarPickerSelectedYear(contextID: string, year: number) { + Onyx.set(ONYXKEYS.CALENDAR_PICKER_SELECTED_YEAR, {contextID, year}); +} + +/** Clears the stored year picker selection once it has been consumed by a CalendarPicker. */ +function clearCalendarPickerSelectedYear() { + Onyx.set(ONYXKEYS.CALENDAR_PICKER_SELECTED_YEAR, null); +} + +export {setCalendarPickerSelectedYear, clearCalendarPickerSelectedYear}; diff --git a/src/pages/ScheduleCall/ScheduleCallPage.tsx b/src/pages/ScheduleCall/ScheduleCallPage.tsx index f92eb15f667b..2de5d99973fa 100644 --- a/src/pages/ScheduleCall/ScheduleCallPage.tsx +++ b/src/pages/ScheduleCall/ScheduleCallPage.tsx @@ -194,6 +194,7 @@ function ScheduleCallPage() { selectableDates={Object.keys(timeSlotDateMap)} DayComponent={AvailableBookingDay} onSelected={loadTimeSlotsAndSaveDate} + pickerContextID="scheduleCall" /> diff --git a/tests/actions/CalendarPickerTest.ts b/tests/actions/CalendarPickerTest.ts new file mode 100644 index 000000000000..54ffe8c9a510 --- /dev/null +++ b/tests/actions/CalendarPickerTest.ts @@ -0,0 +1,37 @@ +import Onyx from 'react-native-onyx'; +import {clearCalendarPickerSelectedYear, setCalendarPickerSelectedYear} from '@src/libs/actions/CalendarPicker'; +import ONYXKEYS from '@src/ONYXKEYS'; +import getOnyxValue from '../utils/getOnyxValue'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +describe('actions/CalendarPicker', () => { + beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + }); + }); + + beforeEach(() => Onyx.clear().then(waitForBatchedUpdates)); + + it('setCalendarPickerSelectedYear stores the selected year with its context in Onyx', async () => { + setCalendarPickerSelectedYear('datePicker-dob', 1995); + await waitForBatchedUpdates(); + + const result = await getOnyxValue(ONYXKEYS.CALENDAR_PICKER_SELECTED_YEAR); + + // The selection is lifted to Onyx so it survives the year picker screen navigation, + // and is tagged with the contextID of the CalendarPicker that opened it. + expect(result).toEqual({contextID: 'datePicker-dob', year: 1995}); + }); + + it('clearCalendarPickerSelectedYear removes the stored selection once it has been consumed', async () => { + setCalendarPickerSelectedYear('scheduleCall', 2031); + await waitForBatchedUpdates(); + expect(await getOnyxValue(ONYXKEYS.CALENDAR_PICKER_SELECTED_YEAR)).toEqual({contextID: 'scheduleCall', year: 2031}); + + clearCalendarPickerSelectedYear(); + await waitForBatchedUpdates(); + + expect(await getOnyxValue(ONYXKEYS.CALENDAR_PICKER_SELECTED_YEAR)).toBeUndefined(); + }); +}); diff --git a/tests/unit/CalendarPickerTest.tsx b/tests/unit/CalendarPickerTest.tsx index cdfec77767ae..73f939beecd8 100644 --- a/tests/unit/CalendarPickerTest.tsx +++ b/tests/unit/CalendarPickerTest.tsx @@ -32,12 +32,6 @@ jest.mock('../../src/hooks/useLocalize', () => jest.mock('@src/components/ConfirmedRoute.tsx'); type MockMonthPickerModalProps = {isVisible: boolean; onMonthChange?: (month: number) => void; onClose?: () => void}; -type MockYearPickerModalProps = { - isVisible: boolean; - years: Array<{value: number; text: string}>; - onYearChange?: (year: number) => void; - onClose?: () => void; -}; jest.mock('@components/DatePicker/CalendarPicker/MonthPickerModal', () => { const ReactNativeActual = jest.requireActual('react-native'); @@ -74,39 +68,16 @@ jest.mock('@components/DatePicker/CalendarPicker/MonthPickerModal', () => { return MockMonthPickerModal; }); -jest.mock('@components/DatePicker/CalendarPicker/YearPickerModal', () => { - const ReactNativeActual = jest.requireActual('react-native'); - const {Pressable, Text, View} = ReactNativeActual; - function MockYearPickerModal({isVisible, years, onYearChange, onClose}: MockYearPickerModalProps) { - if (!isVisible) { - return null; - } - return ( - - {years.map((year) => ( - onYearChange?.(year.value)} - > - {year.text} - - ))} - - close - - - ); - } - return MockYearPickerModal; -}); +const mockNavigate = jest.fn(); +jest.mock('@libs/Navigation/Navigation', () => ({ + __esModule: true, + default: { + navigate: (route: string) => { + mockNavigate(route); + }, + getActiveRoute: jest.fn(() => 'settings/profile'), + }, +})); describe('CalendarPicker', () => { test('renders calendar component', () => { @@ -597,7 +568,8 @@ describe('CalendarPicker', () => { expect(within(screen.getByTestId('currentMonthText')).getByText(monthNames.at(8) ?? '')).toBeTruthy(); }); - test('clicking the year button opens the year picker and selecting a year updates the calendar', () => { + test('clicking the year button navigates to the year picker screen with the current year and context', () => { + mockNavigate.mockClear(); const minDate = new Date('2020-01-01'); const maxDate = new Date('2030-12-31'); const value = '2025-06-15'; @@ -606,27 +578,28 @@ describe('CalendarPicker', () => { value={value} minDate={minDate} maxDate={maxDate} + pickerContextID="datePicker-testInput" />, ); fireEvent.press(screen.getByTestId('currentYearButton')); - const yearPickerModal = screen.getByTestId('YearPickerModal'); - expect(yearPickerModal).toBeTruthy(); - - fireEvent.press(within(yearPickerModal).getByTestId('year-option-2027')); - - expect(within(screen.getByTestId('currentYearText')).getByText('2027')).toBeTruthy(); + expect(mockNavigate).toHaveBeenCalledTimes(1); + const navigatedRoute = mockNavigate.mock.calls.at(0)?.at(0) ?? ''; + expect(navigatedRoute).toContain('year-selector'); + expect(navigatedRoute).toContain('contextID=datePicker-testInput'); + expect(navigatedRoute).toContain('currentYear=2025'); }); - test('closing the year picker via onClose hides the modal', () => { - render(); + test('the year button invokes onBeforeOpenYearPicker before navigating (so popover hosts can dismiss themselves)', () => { + mockNavigate.mockClear(); + const onBeforeOpenYearPicker = jest.fn(); + render(); fireEvent.press(screen.getByTestId('currentYearButton')); - expect(screen.getByTestId('YearPickerModal')).toBeTruthy(); - fireEvent.press(screen.getByTestId('year-modal-close')); - expect(screen.queryByTestId('YearPickerModal')).toBeNull(); + expect(onBeforeOpenYearPicker).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledTimes(1); }); test('closing the month picker via onClose hides the modal', () => { From 883930e05c0d2dac171d8b9f84de33c2de40e9a4 Mon Sep 17 00:00:00 2001 From: Tom Date: Tue, 19 May 2026 12:09:12 -0700 Subject: [PATCH 02/22] Make YearSelector dynamic route reachable from any CalendarPicker host CalendarPicker is a generic component reached from many screens (date input fields, DateSelectPopup, RangeDatePicker, DatePresetFilterBase, ScheduleCallPage, ...). The previous in-place YearPickerModal had no screen restriction, so a hand-maintained entryScreens allowlist silently broke the year picker on omitted screens (e.g. company/personal card transaction start date pages, debug datetime picker). Use the '*' wildcard so the year selector stays reachable from every CalendarPicker host and does not regress when new date-input screens are added. Added a regression test asserting the route remains unrestricted. --- src/ROUTES.ts | 19 ++++++------------- .../DatePicker/CalendarPicker/index.tsx | 4 +--- tests/unit/CalendarPickerTest.tsx | 10 ++++++++++ 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/src/ROUTES.ts b/src/ROUTES.ts index cbd4fc045f56..bb813792909d 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -111,19 +111,12 @@ const DYNAMIC_ROUTES = { YEAR_SELECTOR: { path: 'year-selector', queryParams: ['contextID', 'currentYear', 'minYear', 'maxYear'], - entryScreens: [ - SCREENS.SETTINGS.PROFILE.DATE_OF_BIRTH, - SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_DATE, - SCREENS.MONEY_REQUEST.STEP_DATE, - SCREENS.MONEY_REQUEST.STEP_TIME, - SCREENS.MONEY_REQUEST.STEP_TIME_EDIT, - SCREENS.MONEY_REQUEST.SPLIT_EXPENSE_CREATE_DATE_RANGE, - SCREENS.SEARCH.ROOT, - SCREENS.SEARCH.ADVANCED_FILTERS_DATE_RHP, - SCREENS.SEARCH.EDIT_MULTIPLE_DATE_RHP, - SCREENS.CHRONOS_SCHEDULE_OOO_ROOT, - SCREENS.SCHEDULE_CALL, - ], + // CalendarPicker is a generic component reached from many screens (date input fields, + // DateSelectPopup, RangeDatePicker, DatePresetFilterBase, ScheduleCallPage, ...), and the + // previous in-place YearPickerModal had no screen restriction. Use '*' so the year selector + // remains reachable from every CalendarPicker host and doesn't silently break when new + // date-input screens are added (matches KEYBOARD_SHORTCUTS / EXIT_SURVEY_* generic flows). + entryScreens: ['*'], }, REPORT_SETTINGS_NAME: { path: 'settings/name', diff --git a/src/components/DatePicker/CalendarPicker/index.tsx b/src/components/DatePicker/CalendarPicker/index.tsx index cb189ac54e04..94cf0b11a82e 100644 --- a/src/components/DatePicker/CalendarPicker/index.tsx +++ b/src/components/DatePicker/CalendarPicker/index.tsx @@ -134,9 +134,7 @@ function CalendarPicker({ const openYearPicker = () => { onBeforeOpenYearPicker?.(); Navigation.navigate( - createDynamicRoute( - `${DYNAMIC_ROUTES.YEAR_SELECTOR.path}?contextID=${encodeURIComponent(contextID)}¤tYear=${currentYearView}&minYear=${minYear}&maxYear=${maxYear}`, - ), + createDynamicRoute(`${DYNAMIC_ROUTES.YEAR_SELECTOR.path}?contextID=${encodeURIComponent(contextID)}¤tYear=${currentYearView}&minYear=${minYear}&maxYear=${maxYear}`), ); }; diff --git a/tests/unit/CalendarPickerTest.tsx b/tests/unit/CalendarPickerTest.tsx index 73f939beecd8..7a1590c05393 100644 --- a/tests/unit/CalendarPickerTest.tsx +++ b/tests/unit/CalendarPickerTest.tsx @@ -5,6 +5,7 @@ import type {ComponentType, ReactNode} from 'react'; import CalendarPicker from '@components/DatePicker/CalendarPicker'; import DateUtils from '@libs/DateUtils'; import CONST from '@src/CONST'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; type MockPressableProps = {testID?: string; accessibilityLabel?: string; role?: string; onPress?: () => void; children?: ReactNode}; type MockTextProps = {children?: ReactNode}; @@ -624,4 +625,13 @@ describe('CalendarPicker', () => { expect(allMonths.find((m) => m.value === 6)?.isSelected).toBe(true); expect(allMonths.find((m) => m.value === 0)?.isSelected).toBe(false); }); + + test('the year selector dynamic route is reachable from any CalendarPicker host (not gated to an allowlist)', () => { + // CalendarPicker is rendered from many screens (date input fields, DateSelectPopup, + // RangeDatePicker, DatePresetFilterBase, ScheduleCallPage, ...). The previous in-place + // YearPickerModal had no screen restriction, so the migrated dynamic route must stay + // unrestricted; a partial entryScreens allowlist would silently break the year picker + // on any screen it omits. + expect(DYNAMIC_ROUTES.YEAR_SELECTOR.entryScreens).toContain('*'); + }); }); From 426a1008da4bbc5cf6fcec4824ecf5c92572a53b Mon Sep 17 00:00:00 2001 From: Tom Date: Fri, 22 May 2026 14:12:33 -0700 Subject: [PATCH 03/22] Use Modal.closeTop for popover hosts and useId for the fallback context id --- .../DatePicker/CalendarPicker/index.tsx | 24 ++++++++++++------- src/components/DatePicker/DatePickerModal.tsx | 2 +- .../FilterComponents/DatePresetFilterBase.tsx | 10 ++++---- .../FilterComponents/RangeDatePicker.tsx | 10 ++++---- .../FilterDropdowns/DateSelectPopup/index.tsx | 2 +- tests/unit/CalendarPickerTest.tsx | 22 +++++++++++++---- 6 files changed, 46 insertions(+), 24 deletions(-) diff --git a/src/components/DatePicker/CalendarPicker/index.tsx b/src/components/DatePicker/CalendarPicker/index.tsx index 94cf0b11a82e..b35f60d32561 100644 --- a/src/components/DatePicker/CalendarPicker/index.tsx +++ b/src/components/DatePicker/CalendarPicker/index.tsx @@ -1,6 +1,6 @@ import {addMonths, addYears, format, isSameDay, parseISO, setDate, setMonth, setYear, startOfDay, subMonths, subYears} from 'date-fns'; import {Str} from 'expensify-common'; -import React, {useCallback, useEffect, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useId, useRef, useState} from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; @@ -12,6 +12,7 @@ import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import {clearCalendarPickerSelectedYear} from '@libs/actions/CalendarPicker'; +import {closeTop} from '@libs/actions/Modal'; import DateUtils from '@libs/DateUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; @@ -57,11 +58,11 @@ type CalendarPickerProps = { pickerContextID?: string; /** - * Called right before navigating to the year picker screen. Hosts that render CalendarPicker - * inside a popover/modal use this to dismiss it first, so the year picker screen is not - * rendered behind it. + * Whether the popover/modal hosting this CalendarPicker should be dismissed (via `Modal.closeTop`) + * before navigating to the year picker screen, so the year picker screen is not rendered behind it. + * Wide-screen popover hosts set this; the full-screen mobile overlay leaves it off. */ - onBeforeOpenYearPicker?: () => void; + shouldCloseModalOnYearPickerOpen?: boolean; }; function getInitialCurrentDateView(value: Date | string, minDate: Date, maxDate: Date) { @@ -95,7 +96,7 @@ function CalendarPicker({ headerContainerStyle, shouldEnableMonthYearBackdropInNarrowPane = false, pickerContextID, - onBeforeOpenYearPicker, + shouldCloseModalOnYearPickerOpen = false, }: CalendarPickerProps) { // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth} = useResponsiveLayout(); @@ -106,7 +107,7 @@ function CalendarPicker({ const monthPressableRef = useRef(null); const [currentDateView, setCurrentDateView] = useState(() => getInitialCurrentDateView(value, minDate, maxDate)); const [isMonthPickerVisible, setIsMonthPickerVisible] = useState(false); - const [fallbackContextID] = useState(() => `calendarPicker-${Math.random().toString(36).slice(2)}`); + const fallbackContextID = useId(); const contextID = pickerContextID ?? fallbackContextID; const [selectedYearResult] = useOnyx(ONYXKEYS.CALENDAR_PICKER_SELECTED_YEAR); const isFirstRender = useRef(true); @@ -128,11 +129,18 @@ function CalendarPicker({ } const {year} = selectedYearResult; clearCalendarPickerSelectedYear(); + // Defer applying the year to the next frame: this effect reacts to an Onyx-delivered + // selection, and updating state synchronously inside the effect triggers a cascading + // render (react-hooks/set-state-in-effect). requestAnimationFrame(() => setCurrentDateView((prev) => setYear(new Date(prev), year))); }, [selectedYearResult, contextID]); const openYearPicker = () => { - onBeforeOpenYearPicker?.(); + // Dismiss the popover/modal hosting this CalendarPicker (if any) so the year picker + // screen is not rendered behind it. + if (shouldCloseModalOnYearPickerOpen) { + closeTop(); + } Navigation.navigate( createDynamicRoute(`${DYNAMIC_ROUTES.YEAR_SELECTOR.path}?contextID=${encodeURIComponent(contextID)}¤tYear=${currentYearView}&minYear=${minYear}&maxYear=${maxYear}`), ); diff --git a/src/components/DatePicker/DatePickerModal.tsx b/src/components/DatePicker/DatePickerModal.tsx index 45e3a6441cc8..7b1d2a3a671c 100644 --- a/src/components/DatePicker/DatePickerModal.tsx +++ b/src/components/DatePicker/DatePickerModal.tsx @@ -88,7 +88,7 @@ function DatePickerModal({ onSelected={handleDateSelection} shouldEnableMonthYearBackdropInNarrowPane={shouldEnableMonthYearBackdropInNarrowPane} pickerContextID={`datePicker-${inputID}`} - onBeforeOpenYearPicker={onClose} + shouldCloseModalOnYearPickerOpen /> ); diff --git a/src/components/Search/FilterComponents/DatePresetFilterBase.tsx b/src/components/Search/FilterComponents/DatePresetFilterBase.tsx index 095df8863035..c1251c4c3c2d 100644 --- a/src/components/Search/FilterComponents/DatePresetFilterBase.tsx +++ b/src/components/Search/FilterComponents/DatePresetFilterBase.tsx @@ -103,8 +103,8 @@ type DatePresetFilterBaseProps = { /** Force vertical stacking of calendars in range picker */ forceVerticalCalendars?: boolean; - /** Called right before navigating to the year picker screen (used by popover hosts to dismiss themselves) */ - onBeforeOpenYearPicker?: () => void; + /** Whether the hosting popover should be dismissed (via `Modal.closeTop`) before navigating to the year picker screen */ + shouldCloseModalOnYearPickerOpen?: boolean; /** The ref handle */ ref: Ref; @@ -126,7 +126,7 @@ function DatePresetFilterBase({ onDateValuesChange, onRangeValidationErrorChange, forceVerticalCalendars = false, - onBeforeOpenYearPicker, + shouldCloseModalOnYearPickerOpen = false, ref, }: DatePresetFilterBaseProps) { const theme = useTheme(); @@ -460,7 +460,7 @@ function DatePresetFilterBase({ onRangeValidationErrorChange?.(false); }} forceVertical={forceVerticalCalendars} - onBeforeOpenYearPicker={onBeforeOpenYearPicker} + shouldCloseModalOnYearPickerOpen={shouldCloseModalOnYearPickerOpen} /> ); } @@ -473,7 +473,7 @@ function DatePresetFilterBase({ minDate={CONST.CALENDAR_PICKER.MIN_DATE} maxDate={CONST.CALENDAR_PICKER.MAX_DATE} pickerContextID="searchSingleDate" - onBeforeOpenYearPicker={onBeforeOpenYearPicker} + shouldCloseModalOnYearPickerOpen={shouldCloseModalOnYearPickerOpen} /> void; + /** Whether the hosting popover should be dismissed (via `Modal.closeTop`) before navigating to the year picker screen */ + shouldCloseModalOnYearPickerOpen?: boolean; }; function parseCalendarDate(dateValue?: string): Date | undefined { @@ -37,7 +37,7 @@ function parseCalendarDate(dateValue?: string): Date | undefined { return isValid(parsedDate) ? parsedDate : undefined; } -function RangeDatePicker({fromValue, toValue, onFromSelected, onToSelected, forceVertical = false, onBeforeOpenYearPicker}: RangeDatePickerProps) { +function RangeDatePicker({fromValue, toValue, onFromSelected, onToSelected, forceVertical = false, shouldCloseModalOnYearPickerOpen = false}: RangeDatePickerProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth @@ -58,7 +58,7 @@ function RangeDatePicker({fromValue, toValue, onFromSelected, onToSelected, forc maxDate={fromMaxDate} headerContainerStyle={styles.ph4} pickerContextID="searchRangeFrom" - onBeforeOpenYearPicker={onBeforeOpenYearPicker} + shouldCloseModalOnYearPickerOpen={shouldCloseModalOnYearPickerOpen} /> @@ -73,7 +73,7 @@ function RangeDatePicker({fromValue, toValue, onFromSelected, onToSelected, forc maxDate={CONST.CALENDAR_PICKER.MAX_DATE} headerContainerStyle={styles.ph4} pickerContextID="searchRangeTo" - onBeforeOpenYearPicker={onBeforeOpenYearPicker} + shouldCloseModalOnYearPickerOpen={shouldCloseModalOnYearPickerOpen} /> diff --git a/src/components/Search/FilterDropdowns/DateSelectPopup/index.tsx b/src/components/Search/FilterDropdowns/DateSelectPopup/index.tsx index ea96013de643..208d81081d8a 100644 --- a/src/components/Search/FilterDropdowns/DateSelectPopup/index.tsx +++ b/src/components/Search/FilterDropdowns/DateSelectPopup/index.tsx @@ -154,7 +154,7 @@ function DateSelectPopup({label, value, presets, style, closeOverlay, onChange, presets={presets} onDateValuesChange={updateRangeText} onRangeValidationErrorChange={setShouldShowRangeError} - onBeforeOpenYearPicker={closeOverlay} + shouldCloseModalOnYearPickerOpen /> {shouldShowRangeError && ( { expect(navigatedRoute).toContain('currentYear=2025'); }); - test('the year button invokes onBeforeOpenYearPicker before navigating (so popover hosts can dismiss themselves)', () => { + test('the year button dismisses the host popover before navigating when shouldCloseModalOnYearPickerOpen is set', () => { mockNavigate.mockClear(); - const onBeforeOpenYearPicker = jest.fn(); - render(); + const closeTopSpy = jest.spyOn(Modal, 'closeTop').mockImplementation(() => {}); + render(); fireEvent.press(screen.getByTestId('currentYearButton')); - expect(onBeforeOpenYearPicker).toHaveBeenCalledTimes(1); + expect(closeTopSpy).toHaveBeenCalledTimes(1); expect(mockNavigate).toHaveBeenCalledTimes(1); + closeTopSpy.mockRestore(); + }); + + test('the year button does not dismiss any modal when shouldCloseModalOnYearPickerOpen is not set', () => { + mockNavigate.mockClear(); + const closeTopSpy = jest.spyOn(Modal, 'closeTop').mockImplementation(() => {}); + render(); + + fireEvent.press(screen.getByTestId('currentYearButton')); + + expect(closeTopSpy).not.toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledTimes(1); + closeTopSpy.mockRestore(); }); test('closing the month picker via onClose hides the modal', () => { From cd942200d54efcd5add52be88440ac9b38e3748e Mon Sep 17 00:00:00 2001 From: Tom Date: Sat, 23 May 2026 07:33:16 -0700 Subject: [PATCH 04/22] Require pickerContextID on CalendarPicker and add YEAR_SELECTOR getRoute Make pickerContextID required so every CalendarPicker host owns a stable id; remove the useId-based fallback that regenerated on mount and could mis-route the year picker's selection back to a remounted instance. Add a getRoute handler on the YEAR_SELECTOR dynamic route and use it from CalendarPicker.openYearPicker so the path/queryParams stay co-located with the route definition. --- src/ROUTES.ts | 2 + .../DatePicker/CalendarPicker/index.tsx | 21 ++--- tests/unit/CalendarPickerTest.tsx | 82 +++++++++++-------- 3 files changed, 59 insertions(+), 46 deletions(-) diff --git a/src/ROUTES.ts b/src/ROUTES.ts index bb813792909d..8378ce1699dd 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -117,6 +117,8 @@ const DYNAMIC_ROUTES = { // remains reachable from every CalendarPicker host and doesn't silently break when new // date-input screens are added (matches KEYBOARD_SHORTCUTS / EXIT_SURVEY_* generic flows). entryScreens: ['*'], + getRoute: ({contextID, currentYear, minYear, maxYear}: {contextID: string; currentYear: number; minYear: number; maxYear: number}) => + getUrlWithParams('year-selector', {contextID, currentYear, minYear, maxYear}), }, REPORT_SETTINGS_NAME: { path: 'settings/name', diff --git a/src/components/DatePicker/CalendarPicker/index.tsx b/src/components/DatePicker/CalendarPicker/index.tsx index b35f60d32561..a5aa5dadf7d9 100644 --- a/src/components/DatePicker/CalendarPicker/index.tsx +++ b/src/components/DatePicker/CalendarPicker/index.tsx @@ -1,6 +1,6 @@ import {addMonths, addYears, format, isSameDay, parseISO, setDate, setMonth, setYear, startOfDay, subMonths, subYears} from 'date-fns'; import {Str} from 'expensify-common'; -import React, {useCallback, useEffect, useId, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useRef, useState} from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; @@ -51,11 +51,12 @@ type CalendarPickerProps = { /** * Stable identifier used to match the year selected on the year picker screen back to this - * CalendarPicker instance. Hosts that render CalendarPicker inside a popover/modal that is - * dismissed when navigating (so this component unmounts) must pass a stable id; hosts that - * mount more than one CalendarPicker (e.g. range pickers) must pass distinct ids. + * CalendarPicker instance. Required because the host popover/modal may be dismissed when + * navigating (so this component unmounts and remounts on return); a parent-owned id keeps + * the year selection routed to the correct instance. Hosts that mount more than one + * CalendarPicker (e.g. range pickers) must pass distinct ids. */ - pickerContextID?: string; + pickerContextID: string; /** * Whether the popover/modal hosting this CalendarPicker should be dismissed (via `Modal.closeTop`) @@ -107,8 +108,6 @@ function CalendarPicker({ const monthPressableRef = useRef(null); const [currentDateView, setCurrentDateView] = useState(() => getInitialCurrentDateView(value, minDate, maxDate)); const [isMonthPickerVisible, setIsMonthPickerVisible] = useState(false); - const fallbackContextID = useId(); - const contextID = pickerContextID ?? fallbackContextID; const [selectedYearResult] = useOnyx(ONYXKEYS.CALENDAR_PICKER_SELECTED_YEAR); const isFirstRender = useRef(true); @@ -124,7 +123,7 @@ function CalendarPicker({ // When the year picker screen writes back a selection for this CalendarPicker instance, // apply it to the displayed date and clear the transient result so it isn't re-applied. useEffect(() => { - if (!selectedYearResult || selectedYearResult.contextID !== contextID) { + if (!selectedYearResult || selectedYearResult.contextID !== pickerContextID) { return; } const {year} = selectedYearResult; @@ -133,7 +132,7 @@ function CalendarPicker({ // selection, and updating state synchronously inside the effect triggers a cascading // render (react-hooks/set-state-in-effect). requestAnimationFrame(() => setCurrentDateView((prev) => setYear(new Date(prev), year))); - }, [selectedYearResult, contextID]); + }, [selectedYearResult, pickerContextID]); const openYearPicker = () => { // Dismiss the popover/modal hosting this CalendarPicker (if any) so the year picker @@ -141,9 +140,7 @@ function CalendarPicker({ if (shouldCloseModalOnYearPickerOpen) { closeTop(); } - Navigation.navigate( - createDynamicRoute(`${DYNAMIC_ROUTES.YEAR_SELECTOR.path}?contextID=${encodeURIComponent(contextID)}¤tYear=${currentYearView}&minYear=${minYear}&maxYear=${maxYear}`), - ); + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.YEAR_SELECTOR.getRoute({contextID: pickerContextID, currentYear: currentYearView, minYear, maxYear}))); }; const onMonthSelected = (month: number) => { diff --git a/tests/unit/CalendarPickerTest.tsx b/tests/unit/CalendarPickerTest.tsx index 7ded4aaf30cf..25b1dbb095ee 100644 --- a/tests/unit/CalendarPickerTest.tsx +++ b/tests/unit/CalendarPickerTest.tsx @@ -1,7 +1,7 @@ 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 type {ComponentType, ReactNode} from 'react'; +import type {ComponentProps, ComponentType, ReactNode} from 'react'; import CalendarPicker from '@components/DatePicker/CalendarPicker'; import * as Modal from '@libs/actions/Modal'; import DateUtils from '@libs/DateUtils'; @@ -81,9 +81,23 @@ jest.mock('@libs/Navigation/Navigation', () => ({ }, })); +// CalendarPicker's pickerContextID prop is required (callers must own a stable id so the year +// picker's selection routes back to the correct instance after popover dismiss/remount). This +// wrapper supplies a default for tests that don't exercise the contextID itself; tests that do +// pass their own pickerContextID, which the spread overrides. +function CalendarPickerForTest({pickerContextID = 'test-calendar', ...rest}: Omit, 'pickerContextID'> & {pickerContextID?: string}) { + return ( + + ); +} + describe('CalendarPicker', () => { test('renders calendar component', () => { - render(); + render(); }); test('displays the current month and year', () => { @@ -91,7 +105,7 @@ describe('CalendarPicker', () => { const maxDate = addYears(new Date(currentDate), 1); const minDate = subYears(new Date(currentDate), 1); render( - , @@ -105,7 +119,7 @@ describe('CalendarPicker', () => { const minDate = new Date('2022-01-01'); const maxDate = new Date('2030-01-01'); render( - , @@ -118,7 +132,7 @@ describe('CalendarPicker', () => { }); test('clicking previous month arrow updates the displayed month', () => { - render(); + render(); fireEvent.press(screen.getByTestId('prev-month-arrow')); @@ -132,7 +146,7 @@ describe('CalendarPicker', () => { const maxDate = new Date('2030-01-01'); const value = '2023-01-01'; render( - { const minDate = new Date('2022-01-01'); const maxDate = new Date('2030-01-01'); render( - { const value = new Date('2003-02-17'); render( - , @@ -189,7 +203,7 @@ describe('CalendarPicker', () => { const maxDate = new Date('2003-02-24'); const value = new Date('2003-02-17'); render( - , @@ -209,7 +223,7 @@ describe('CalendarPicker', () => { // given the max date is 27 render( - , @@ -223,7 +237,7 @@ describe('CalendarPicker', () => { const onSelectedMock = jest.fn(); const maxDate = new Date('2011-03-01'); render( - , @@ -236,7 +250,7 @@ describe('CalendarPicker', () => { test('should open the calendar on a year from max date if it is earlier than current year', () => { const maxDate = new Date('2011-03-01'); - render(); + render(); expect(within(screen.getByTestId('currentYearText')).getByText('2011')).toBeTruthy(); }); @@ -245,7 +259,7 @@ describe('CalendarPicker', () => { const minDate = new Date('2035-02-16'); const maxDate = new Date('2040-02-16'); render( - , @@ -261,7 +275,7 @@ describe('CalendarPicker', () => { // given the min date is 16 render( - { // given the max date is 24 render( - { // given the min date is 16 render( - , @@ -330,7 +344,7 @@ describe('CalendarPicker', () => { // given the max date is 24 render( - , @@ -345,7 +359,7 @@ describe('CalendarPicker', () => { const maxDate = new Date('2030-12-31'); const value = '2025-06-15'; render( - { const maxDate = new Date('2030-12-31'); const value = '2025-06-15'; render( - { const minDate = new Date('2023-01-01'); const value = new Date('2023-06-15'); render( - , @@ -395,7 +409,7 @@ describe('CalendarPicker', () => { const maxDate = new Date('2023-12-31'); const value = new Date('2023-06-15'); render( - , @@ -413,7 +427,7 @@ describe('CalendarPicker', () => { const maxDate = new Date('2030-12-31'); const value = '2024-03-15'; render( - { const maxDate = new Date('2025-04-20'); const value = '2024-09-15'; render( - { const maxDate = new Date('2030-12-31'); const value = '2025-12-15'; render( - { const maxDate = new Date('2030-12-31'); const value = '2025-01-15'; render( - { const value = new Date(CONST.CALENDAR_PICKER.MAX_YEAR, 5, 15); const maxDate = new Date(CONST.CALENDAR_PICKER.MAX_YEAR, 11, 31); render( - , @@ -502,7 +516,7 @@ describe('CalendarPicker', () => { const value = new Date(CONST.CALENDAR_PICKER.MIN_YEAR, 5, 15); const minDate = new Date(CONST.CALENDAR_PICKER.MIN_YEAR, 0, 1); render( - , @@ -518,7 +532,7 @@ describe('CalendarPicker', () => { const value = new Date(CONST.CALENDAR_PICKER.MAX_YEAR, 11, 15); const maxDate = new Date(CONST.CALENDAR_PICKER.MAX_YEAR, 11, 31); render( - , @@ -535,7 +549,7 @@ describe('CalendarPicker', () => { const value = new Date(CONST.CALENDAR_PICKER.MIN_YEAR, 0, 15); const minDate = new Date(CONST.CALENDAR_PICKER.MIN_YEAR, 0, 1); render( - , @@ -553,7 +567,7 @@ describe('CalendarPicker', () => { const maxDate = new Date('2030-12-31'); const value = '2025-06-15'; render( - { const maxDate = new Date('2030-12-31'); const value = '2025-06-15'; render( - { test('the year button dismisses the host popover before navigating when shouldCloseModalOnYearPickerOpen is set', () => { mockNavigate.mockClear(); const closeTopSpy = jest.spyOn(Modal, 'closeTop').mockImplementation(() => {}); - render(); + render(); fireEvent.press(screen.getByTestId('currentYearButton')); @@ -608,7 +622,7 @@ describe('CalendarPicker', () => { test('the year button does not dismiss any modal when shouldCloseModalOnYearPickerOpen is not set', () => { mockNavigate.mockClear(); const closeTopSpy = jest.spyOn(Modal, 'closeTop').mockImplementation(() => {}); - render(); + render(); fireEvent.press(screen.getByTestId('currentYearButton')); @@ -618,7 +632,7 @@ describe('CalendarPicker', () => { }); test('closing the month picker via onClose hides the modal', () => { - render(); + render(); fireEvent.press(screen.getByTestId('currentMonthButton')); expect(screen.getByTestId('MonthPickerModal')).toBeTruthy(); From 46ff704261038cd53a3e73928633ad4475b0ed8f Mon Sep 17 00:00:00 2001 From: Tom Date: Sat, 23 May 2026 10:07:47 -0700 Subject: [PATCH 05/22] Use createElement in CalendarPickerForTest wrapper to satisfy jsx-props-no-spreading --- tests/unit/CalendarPickerTest.tsx | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/tests/unit/CalendarPickerTest.tsx b/tests/unit/CalendarPickerTest.tsx index 25b1dbb095ee..227f3978a718 100644 --- a/tests/unit/CalendarPickerTest.tsx +++ b/tests/unit/CalendarPickerTest.tsx @@ -1,6 +1,7 @@ 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 {createElement} from 'react'; import type {ComponentProps, ComponentType, ReactNode} from 'react'; import CalendarPicker from '@components/DatePicker/CalendarPicker'; import * as Modal from '@libs/actions/Modal'; @@ -81,18 +82,10 @@ jest.mock('@libs/Navigation/Navigation', () => ({ }, })); -// CalendarPicker's pickerContextID prop is required (callers must own a stable id so the year -// picker's selection routes back to the correct instance after popover dismiss/remount). This -// wrapper supplies a default for tests that don't exercise the contextID itself; tests that do -// pass their own pickerContextID, which the spread overrides. +// CalendarPicker's pickerContextID is required (callers own a stable id so the year picker +// routes back to the correct instance). Tests default to 'test-calendar' unless they override it. function CalendarPickerForTest({pickerContextID = 'test-calendar', ...rest}: Omit, 'pickerContextID'> & {pickerContextID?: string}) { - return ( - - ); + return createElement(CalendarPicker, {pickerContextID, ...rest}); } describe('CalendarPicker', () => { From b8623b257d447dfd45698ef41893edbc97e1e30c Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 1 Jun 2026 18:16:25 -0700 Subject: [PATCH 06/22] Keep date picker mounted while year selector is open on web wide-screen On web wide-screen the date picker popover is a portal (z-index 9996) that paints above the navigation stack, so the migrated year-selector RHP rendered behind it. Instead of dismissing the popover, keep it mounted and hide its content (opacity + non-interactive) while the year selector is focused, then restore on return. Narrow screens and native keep closing the popover, where there's no portal layering and the dismiss gives the expected slide. --- src/components/DatePicker/CalendarPicker/index.tsx | 11 ++++++++++- src/components/DatePicker/DatePickerModal.tsx | 5 ++++- .../FilterComponents/DatePresetFilterBase.tsx | 5 +++++ .../Search/FilterComponents/RangeDatePicker.tsx | 14 +++++++++++++- .../FilterDropdowns/DateSelectPopup/index.tsx | 6 ++++-- 5 files changed, 36 insertions(+), 5 deletions(-) diff --git a/src/components/DatePicker/CalendarPicker/index.tsx b/src/components/DatePicker/CalendarPicker/index.tsx index a5aa5dadf7d9..154c569782ff 100644 --- a/src/components/DatePicker/CalendarPicker/index.tsx +++ b/src/components/DatePicker/CalendarPicker/index.tsx @@ -1,3 +1,4 @@ +import {findFocusedRoute} from '@react-navigation/native'; import {addMonths, addYears, format, isSameDay, parseISO, setDate, setMonth, setYear, startOfDay, subMonths, subYears} from 'date-fns'; import {Str} from 'expensify-common'; import React, {useCallback, useEffect, useRef, useState} from 'react'; @@ -10,6 +11,7 @@ import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useRootNavigationState from '@hooks/useRootNavigationState'; import useThemeStyles from '@hooks/useThemeStyles'; import {clearCalendarPickerSelectedYear} from '@libs/actions/CalendarPicker'; import {closeTop} from '@libs/actions/Modal'; @@ -19,6 +21,7 @@ import Navigation from '@libs/Navigation/Navigation'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; import ArrowIcon from './ArrowIcon'; import Day from './Day'; import generateMonthMatrix from './generateMonthMatrix'; @@ -64,6 +67,9 @@ type CalendarPickerProps = { * Wide-screen popover hosts set this; the full-screen mobile overlay leaves it off. */ shouldCloseModalOnYearPickerOpen?: boolean; + + /** On web wide-screen the host keeps this CalendarPicker mounted while the year-picker RHP is open; when true, hide the calendar (opacity 0 + non-interactive) so the RHP renders on top, then restore on return. */ + shouldHideOnYearPickerOpen?: boolean; }; function getInitialCurrentDateView(value: Date | string, minDate: Date, maxDate: Date) { @@ -98,11 +104,14 @@ function CalendarPicker({ shouldEnableMonthYearBackdropInNarrowPane = false, pickerContextID, shouldCloseModalOnYearPickerOpen = false, + shouldHideOnYearPickerOpen = false, }: CalendarPickerProps) { // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth} = useResponsiveLayout(); const styles = useThemeStyles(); const themeStyles = useThemeStyles(); + const isYearPickerOpen = useRootNavigationState((state) => (state ? findFocusedRoute(state)?.name === SCREENS.SETTINGS.DYNAMIC_YEAR_SELECTOR : false)); + const isHiddenForYearPicker = shouldHideOnYearPickerOpen && isYearPickerOpen; const {translate} = useLocalize(); const pressableRef = useRef(null); const monthPressableRef = useRef(null); @@ -241,7 +250,7 @@ function CalendarPicker({ const getAccessibilityState = useCallback((isSelected: boolean) => ({selected: isSelected}), []); return ( - + { if (shouldSaveDraft && formID) { @@ -88,7 +90,8 @@ function DatePickerModal({ onSelected={handleDateSelection} shouldEnableMonthYearBackdropInNarrowPane={shouldEnableMonthYearBackdropInNarrowPane} pickerContextID={`datePicker-${inputID}`} - shouldCloseModalOnYearPickerOpen + shouldCloseModalOnYearPickerOpen={!shouldHideInPlace} + shouldHideOnYearPickerOpen={shouldHideInPlace} /> ); diff --git a/src/components/Search/FilterComponents/DatePresetFilterBase.tsx b/src/components/Search/FilterComponents/DatePresetFilterBase.tsx index c1251c4c3c2d..13d1fd170800 100644 --- a/src/components/Search/FilterComponents/DatePresetFilterBase.tsx +++ b/src/components/Search/FilterComponents/DatePresetFilterBase.tsx @@ -106,6 +106,8 @@ type DatePresetFilterBaseProps = { /** Whether the hosting popover should be dismissed (via `Modal.closeTop`) before navigating to the year picker screen */ shouldCloseModalOnYearPickerOpen?: boolean; + shouldHideOnYearPickerOpen?: boolean; + /** The ref handle */ ref: Ref; }; @@ -127,6 +129,7 @@ function DatePresetFilterBase({ onRangeValidationErrorChange, forceVerticalCalendars = false, shouldCloseModalOnYearPickerOpen = false, + shouldHideOnYearPickerOpen = false, ref, }: DatePresetFilterBaseProps) { const theme = useTheme(); @@ -461,6 +464,7 @@ function DatePresetFilterBase({ }} forceVertical={forceVerticalCalendars} shouldCloseModalOnYearPickerOpen={shouldCloseModalOnYearPickerOpen} + shouldHideOnYearPickerOpen={shouldHideOnYearPickerOpen} /> ); } @@ -474,6 +478,7 @@ function DatePresetFilterBase({ maxDate={CONST.CALENDAR_PICKER.MAX_DATE} pickerContextID="searchSingleDate" shouldCloseModalOnYearPickerOpen={shouldCloseModalOnYearPickerOpen} + shouldHideOnYearPickerOpen={shouldHideOnYearPickerOpen} /> @@ -74,6 +85,7 @@ function RangeDatePicker({fromValue, toValue, onFromSelected, onToSelected, forc headerContainerStyle={styles.ph4} pickerContextID="searchRangeTo" shouldCloseModalOnYearPickerOpen={shouldCloseModalOnYearPickerOpen} + shouldHideOnYearPickerOpen={shouldHideOnYearPickerOpen} /> diff --git a/src/components/Search/FilterDropdowns/DateSelectPopup/index.tsx b/src/components/Search/FilterDropdowns/DateSelectPopup/index.tsx index 208d81081d8a..c427c3b82008 100644 --- a/src/components/Search/FilterDropdowns/DateSelectPopup/index.tsx +++ b/src/components/Search/FilterDropdowns/DateSelectPopup/index.tsx @@ -1,5 +1,5 @@ import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import {View} from 'react-native'; +import {Platform, View} from 'react-native'; import type {StyleProp, ViewStyle} from 'react-native'; import FormHelpMessage from '@components/FormHelpMessage'; import ScrollView from '@components/ScrollView'; @@ -44,6 +44,7 @@ type DateSelectPopupProps = { function DateSelectPopup({label, value, presets, style, closeOverlay, onChange, setPopoverWidth}: DateSelectPopupProps) { // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth, isInLandscapeMode} = useResponsiveLayout(); + const shouldHideInPlace = Platform.OS === 'web' && !isSmallScreenWidth; const {translate} = useLocalize(); const styles = useThemeStyles(); @@ -154,7 +155,8 @@ function DateSelectPopup({label, value, presets, style, closeOverlay, onChange, presets={presets} onDateValuesChange={updateRangeText} onRangeValidationErrorChange={setShouldShowRangeError} - shouldCloseModalOnYearPickerOpen + shouldCloseModalOnYearPickerOpen={!shouldHideInPlace} + shouldHideOnYearPickerOpen={shouldHideInPlace} /> {shouldShowRangeError && ( Date: Tue, 2 Jun 2026 06:44:05 -0700 Subject: [PATCH 07/22] Fix CalendarPicker unit test for the new root-navigation-state read The year-selector hide logic reads the root navigation state via useRootNavigationState. CalendarPickerTest renders the picker without a NavigationContainer, so its mock left navigationRef undefined and the hook threw on navigationRef.isReady(). Mock the hook to the navigation-not-ready default so the suite renders the picker as before. --- tests/unit/CalendarPickerTest.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/unit/CalendarPickerTest.tsx b/tests/unit/CalendarPickerTest.tsx index 227f3978a718..45f7db23ed89 100644 --- a/tests/unit/CalendarPickerTest.tsx +++ b/tests/unit/CalendarPickerTest.tsx @@ -26,6 +26,13 @@ jest.mock('@react-navigation/native', () => ({ createNavigationContainerRef: jest.fn(), })); +// CalendarPicker reads the root navigation state to hide itself while the year selector is open. +// This test renders it without a NavigationContainer, so mock the hook to the "navigation not ready" default. +jest.mock('@hooks/useRootNavigationState', () => ({ + __esModule: true, + default: (selector: (state: undefined) => T): T => selector(undefined), +})); + jest.mock('../../src/hooks/useLocalize', () => jest.fn(() => ({ translate: jest.fn(), From d831e3ad01c2c2f69f5cc5a6e32c6d543ce04d21 Mon Sep 17 00:00:00 2001 From: Tom Date: Fri, 5 Jun 2026 17:32:12 -0700 Subject: [PATCH 08/22] Address review: visibilityHidden on year-picker hide + getPlatform() over Platform.OS --- src/components/DatePicker/CalendarPicker/index.tsx | 10 +++++++++- src/components/DatePicker/DatePickerModal.tsx | 4 ++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/components/DatePicker/CalendarPicker/index.tsx b/src/components/DatePicker/CalendarPicker/index.tsx index 154c569782ff..69d0cc7650c6 100644 --- a/src/components/DatePicker/CalendarPicker/index.tsx +++ b/src/components/DatePicker/CalendarPicker/index.tsx @@ -250,7 +250,15 @@ function CalendarPicker({ const getAccessibilityState = useCallback((isSelected: boolean) => ({selected: isSelected}), []); return ( - + { if (shouldSaveDraft && formID) { From 57f29696d39ca8d22e733aae8e9fa856dfa0d478 Mon Sep 17 00:00:00 2001 From: Tom Date: Sat, 6 Jun 2026 16:50:00 -0700 Subject: [PATCH 09/22] Hide whole year-picker host popover instead of inner calendar; drop redundant inner-hide The year selector is a @react-navigation route; opening it pushed an RHP over the still-visible date popover (only the inner calendar was hidden) and the goBack popstate could close the host. Each wide-screen host (DatePickerModal, and DropdownButton which owns the search filter popover) now hides its whole popover frame while the year-selector route is focused and suppresses the popstate close, via a shared useIsYearSelectorOpen hook. Removes the per-CalendarPicker shouldHideOnYearPickerOpen inner-hide. The selected year still applies on return via the existing pickerContextID/Onyx write-back. --- .../DatePicker/CalendarPicker/index.tsx | 19 +------------------ src/components/DatePicker/DatePickerModal.tsx | 11 ++++++++--- .../DatePicker/useIsYearSelectorOpen.ts | 17 +++++++++++++++++ .../FilterComponents/DatePresetFilterBase.tsx | 5 ----- .../FilterComponents/RangeDatePicker.tsx | 14 +------------- .../FilterDropdowns/DateSelectPopup/index.tsx | 1 - .../Search/FilterDropdowns/DropdownButton.tsx | 10 +++++++++- tests/unit/CalendarPickerTest.tsx | 7 ------- 8 files changed, 36 insertions(+), 48 deletions(-) create mode 100644 src/components/DatePicker/useIsYearSelectorOpen.ts diff --git a/src/components/DatePicker/CalendarPicker/index.tsx b/src/components/DatePicker/CalendarPicker/index.tsx index 69d0cc7650c6..a5aa5dadf7d9 100644 --- a/src/components/DatePicker/CalendarPicker/index.tsx +++ b/src/components/DatePicker/CalendarPicker/index.tsx @@ -1,4 +1,3 @@ -import {findFocusedRoute} from '@react-navigation/native'; import {addMonths, addYears, format, isSameDay, parseISO, setDate, setMonth, setYear, startOfDay, subMonths, subYears} from 'date-fns'; import {Str} from 'expensify-common'; import React, {useCallback, useEffect, useRef, useState} from 'react'; @@ -11,7 +10,6 @@ import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useRootNavigationState from '@hooks/useRootNavigationState'; import useThemeStyles from '@hooks/useThemeStyles'; import {clearCalendarPickerSelectedYear} from '@libs/actions/CalendarPicker'; import {closeTop} from '@libs/actions/Modal'; @@ -21,7 +19,6 @@ import Navigation from '@libs/Navigation/Navigation'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; -import SCREENS from '@src/SCREENS'; import ArrowIcon from './ArrowIcon'; import Day from './Day'; import generateMonthMatrix from './generateMonthMatrix'; @@ -67,9 +64,6 @@ type CalendarPickerProps = { * Wide-screen popover hosts set this; the full-screen mobile overlay leaves it off. */ shouldCloseModalOnYearPickerOpen?: boolean; - - /** On web wide-screen the host keeps this CalendarPicker mounted while the year-picker RHP is open; when true, hide the calendar (opacity 0 + non-interactive) so the RHP renders on top, then restore on return. */ - shouldHideOnYearPickerOpen?: boolean; }; function getInitialCurrentDateView(value: Date | string, minDate: Date, maxDate: Date) { @@ -104,14 +98,11 @@ function CalendarPicker({ shouldEnableMonthYearBackdropInNarrowPane = false, pickerContextID, shouldCloseModalOnYearPickerOpen = false, - shouldHideOnYearPickerOpen = false, }: CalendarPickerProps) { // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth} = useResponsiveLayout(); const styles = useThemeStyles(); const themeStyles = useThemeStyles(); - const isYearPickerOpen = useRootNavigationState((state) => (state ? findFocusedRoute(state)?.name === SCREENS.SETTINGS.DYNAMIC_YEAR_SELECTOR : false)); - const isHiddenForYearPicker = shouldHideOnYearPickerOpen && isYearPickerOpen; const {translate} = useLocalize(); const pressableRef = useRef(null); const monthPressableRef = useRef(null); @@ -250,15 +241,7 @@ function CalendarPicker({ const getAccessibilityState = useCallback((isSelected: boolean) => ({selected: isSelected}), []); return ( - + { if (shouldSaveDraft && formID) { @@ -68,11 +70,15 @@ function DatePickerModal({ return ( ); diff --git a/src/components/DatePicker/useIsYearSelectorOpen.ts b/src/components/DatePicker/useIsYearSelectorOpen.ts new file mode 100644 index 000000000000..e7870a2058ea --- /dev/null +++ b/src/components/DatePicker/useIsYearSelectorOpen.ts @@ -0,0 +1,17 @@ +import {findFocusedRoute} from '@react-navigation/native'; +import useRootNavigationState from '@hooks/useRootNavigationState'; +import SCREENS from '@src/SCREENS'; + +/** + * Whether the dynamic year-selector route is the currently focused screen. + * + * The year selector is a @react-navigation route, so opening it pushes a screen and selecting a year does a + * `goBack` (a real browser history change). Wide-screen popover hosts that keep their date picker mounted use + * this to (1) hide their whole popover frame while the selector is open and (2) suppress their + * `shouldCloseWhenBrowserNavigationChanged` listener so the `goBack` doesn't tear the host down. + */ +function useIsYearSelectorOpen(): boolean { + return useRootNavigationState((state) => (state ? findFocusedRoute(state)?.name === SCREENS.SETTINGS.DYNAMIC_YEAR_SELECTOR : false)); +} + +export default useIsYearSelectorOpen; diff --git a/src/components/Search/FilterComponents/DatePresetFilterBase.tsx b/src/components/Search/FilterComponents/DatePresetFilterBase.tsx index 13d1fd170800..c1251c4c3c2d 100644 --- a/src/components/Search/FilterComponents/DatePresetFilterBase.tsx +++ b/src/components/Search/FilterComponents/DatePresetFilterBase.tsx @@ -106,8 +106,6 @@ type DatePresetFilterBaseProps = { /** Whether the hosting popover should be dismissed (via `Modal.closeTop`) before navigating to the year picker screen */ shouldCloseModalOnYearPickerOpen?: boolean; - shouldHideOnYearPickerOpen?: boolean; - /** The ref handle */ ref: Ref; }; @@ -129,7 +127,6 @@ function DatePresetFilterBase({ onRangeValidationErrorChange, forceVerticalCalendars = false, shouldCloseModalOnYearPickerOpen = false, - shouldHideOnYearPickerOpen = false, ref, }: DatePresetFilterBaseProps) { const theme = useTheme(); @@ -464,7 +461,6 @@ function DatePresetFilterBase({ }} forceVertical={forceVerticalCalendars} shouldCloseModalOnYearPickerOpen={shouldCloseModalOnYearPickerOpen} - shouldHideOnYearPickerOpen={shouldHideOnYearPickerOpen} /> ); } @@ -478,7 +474,6 @@ function DatePresetFilterBase({ maxDate={CONST.CALENDAR_PICKER.MAX_DATE} pickerContextID="searchSingleDate" shouldCloseModalOnYearPickerOpen={shouldCloseModalOnYearPickerOpen} - shouldHideOnYearPickerOpen={shouldHideOnYearPickerOpen} /> @@ -85,7 +74,6 @@ function RangeDatePicker({ headerContainerStyle={styles.ph4} pickerContextID="searchRangeTo" shouldCloseModalOnYearPickerOpen={shouldCloseModalOnYearPickerOpen} - shouldHideOnYearPickerOpen={shouldHideOnYearPickerOpen} /> diff --git a/src/components/Search/FilterDropdowns/DateSelectPopup/index.tsx b/src/components/Search/FilterDropdowns/DateSelectPopup/index.tsx index c427c3b82008..7e96b4cf738e 100644 --- a/src/components/Search/FilterDropdowns/DateSelectPopup/index.tsx +++ b/src/components/Search/FilterDropdowns/DateSelectPopup/index.tsx @@ -156,7 +156,6 @@ function DateSelectPopup({label, value, presets, style, closeOverlay, onChange, onDateValuesChange={updateRangeText} onRangeValidationErrorChange={setShouldShowRangeError} shouldCloseModalOnYearPickerOpen={!shouldHideInPlace} - shouldHideOnYearPickerOpen={shouldHideInPlace} /> {shouldShowRangeError && ( ({ createNavigationContainerRef: jest.fn(), })); -// CalendarPicker reads the root navigation state to hide itself while the year selector is open. -// This test renders it without a NavigationContainer, so mock the hook to the "navigation not ready" default. -jest.mock('@hooks/useRootNavigationState', () => ({ - __esModule: true, - default: (selector: (state: undefined) => T): T => selector(undefined), -})); - jest.mock('../../src/hooks/useLocalize', () => jest.fn(() => ({ translate: jest.fn(), From c9c2bea6a294526aa73d49bfcc490494247bf8eb Mon Sep 17 00:00:00 2001 From: Tom Date: Sat, 6 Jun 2026 17:05:35 -0700 Subject: [PATCH 10/22] Mock useRootNavigationState in IOU/time confirmation tests that render DatePickerModal DatePickerModal now mounts useRootNavigationState (via useIsYearSelectorOpen) unconditionally; these suites render it without a NavigationContainer, so navigationRef is a bare mock and isReady() throws. Mock the hook to the navigation-not-ready default, matching the existing CalendarPickerTest pattern. --- tests/ui/TimeExpenseConfirmationTest.tsx | 8 ++++++++ .../ui/components/IOURequestStepConfirmationPageTest.tsx | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/tests/ui/TimeExpenseConfirmationTest.tsx b/tests/ui/TimeExpenseConfirmationTest.tsx index 7a2bc8376319..fb21c835264b 100644 --- a/tests/ui/TimeExpenseConfirmationTest.tsx +++ b/tests/ui/TimeExpenseConfirmationTest.tsx @@ -20,6 +20,14 @@ import createRandomPolicy from '../utils/collections/policies'; import {signInWithTestUser} from '../utils/TestHelper'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; +// DatePickerModal (rendered by the confirmation page's date field) mounts useRootNavigationState (via +// useIsYearSelectorOpen) to detect the year-selector route; this suite renders without a NavigationContainer, +// so mock it to the "navigation not ready" default. +jest.mock('@hooks/useRootNavigationState', () => ({ + __esModule: true, + default: (selector: (state: undefined) => T): T => selector(undefined), +})); + jest.mock('@components/MenuItemWithTopDescription', () => { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const {View, Text} = require('react-native'); diff --git a/tests/ui/components/IOURequestStepConfirmationPageTest.tsx b/tests/ui/components/IOURequestStepConfirmationPageTest.tsx index 5664e457666a..a40d1b33f030 100644 --- a/tests/ui/components/IOURequestStepConfirmationPageTest.tsx +++ b/tests/ui/components/IOURequestStepConfirmationPageTest.tsx @@ -20,6 +20,14 @@ import createRandomPolicy from '../../utils/collections/policies'; import {signInWithTestUser, translateLocal} from '../../utils/TestHelper'; import waitForBatchedUpdatesWithAct from '../../utils/waitForBatchedUpdatesWithAct'; +// DatePickerModal (rendered by the confirmation page's date field) mounts useRootNavigationState (via +// useIsYearSelectorOpen) to detect the year-selector route; this suite renders without a NavigationContainer, +// so mock it to the "navigation not ready" default. +jest.mock('@hooks/useRootNavigationState', () => ({ + __esModule: true, + default: (selector: (state: undefined) => T): T => selector(undefined), +})); + jest.mock('@rnmapbox/maps', () => { return { default: jest.fn(), From b1d1f4ce8bc2e4824ad10e247b30f9397b24d3d6 Mon Sep 17 00:00:00 2001 From: Tom Date: Sat, 13 Jun 2026 07:45:39 -0700 Subject: [PATCH 11/22] Preserve search date-filter view state across the year-selector round-trip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The year-selector screen blurs the Search screen, so the filter popover host (FilterPopupButton) unmounts and remounts fresh on goBack — resetting the advanced Filters menu to the default Type filter and the date Custom-date/range sub-view to the top menu (and losing the user's place after picking a year). Persist the date sub-view (selectedDateModifier) in Onyx and restore it on return (guarded on a pending year write-back for a search* calendar context), and reopen the advanced Filters menu to the Date filter on return. Mirrors the existing year write-back; covers both the advanced Filters menu (AdvancedFiltersPopup + DateFilterComponent) and the inline Date filter chip (DateSelectPopup). --- src/ONYXKEYS.ts | 4 +++ .../AdvancedFilters/DateFilterComponent.tsx | 31 ++++++++++++++++++- .../AdvancedFiltersPopup/index.tsx | 15 ++++++++- .../FilterDropdowns/DateSelectPopup/index.tsx | 29 +++++++++++++++++ src/libs/actions/CalendarPicker.ts | 10 +++++- 5 files changed, 86 insertions(+), 3 deletions(-) diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 3194fecb54f0..2333b539dd30 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -617,6 +617,9 @@ const ONYXKEYS = { /** Stores the year selected in the year picker so it can be read back by the CalendarPicker that opened it */ CALENDAR_PICKER_SELECTED_YEAR: 'calendarPickerSelectedYear', + /** Stores the active Search date-filter sub-view (Custom date/range modifier) so the date filter can restore it after the year picker screen unmounts and remounts it */ + CALENDAR_PICKER_SELECTED_DATE_MODIFIER: 'calendarPickerSelectedDateModifier', + /** Stores the route to open after changing app permission from settings */ LAST_ROUTE: 'lastRoute', @@ -1632,6 +1635,7 @@ type OnyxValuesMapping = { [ONYXKEYS.IMPORTED_SPREADSHEET_MEMBER_DATA]: OnyxTypes.ImportedSpreadsheetMemberData[]; [ONYXKEYS.IMPORTED_SPREADSHEET_MEMBER_ROLE]: ValueOf; [ONYXKEYS.CALENDAR_PICKER_SELECTED_YEAR]: {contextID: string; year: number}; + [ONYXKEYS.CALENDAR_PICKER_SELECTED_DATE_MODIFIER]: string; [ONYXKEYS.LAST_ROUTE]: string; [ONYXKEYS.IS_USING_IMPORTED_STATE]: boolean; [ONYXKEYS.NVP_EXPENSIFY_COMPANY_CARDS_CUSTOM_NAMES]: Record; diff --git a/src/components/Search/FilterComponents/AdvancedFilters/DateFilterComponent.tsx b/src/components/Search/FilterComponents/AdvancedFilters/DateFilterComponent.tsx index cb92307c0831..3b5f9886acc3 100644 --- a/src/components/Search/FilterComponents/AdvancedFilters/DateFilterComponent.tsx +++ b/src/components/Search/FilterComponents/AdvancedFilters/DateFilterComponent.tsx @@ -1,4 +1,4 @@ -import React, {useRef, useState} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import Button from '@components/Button'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; @@ -7,12 +7,15 @@ import type {DateFilterBaseHandle} from '@components/Search/FilterComponents/Dat import useFullscreenAdvancedFilters from '@components/Search/FilterDropdowns/AdvancedFilters/useFullscreenAdvancedFilters'; import type {SearchDateFilterKeys} from '@components/Search/types'; import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; +import {setCalendarPickerSelectedDateModifier} from '@libs/actions/CalendarPicker'; import {getDateModifierTitle} from '@libs/SearchQueryUtils'; import type {SearchDateValues} from '@libs/SearchQueryUtils'; import {getDatePresets} from '@libs/SearchUIUtils'; import type {SearchDateModifier} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import type {SearchAdvancedFiltersForm} from '@src/types/form'; type DateFilterComponentProps = { @@ -31,6 +34,32 @@ function DateFilterComponent({filterKey, value: initialValue, hasFeed, onChange} const [selectedDateModifier, setSelectedDateModifier] = useState(null); const [value, setValue] = useState(initialValue); + // Opening the year picker blurs the Search screen and unmounts this popover, resetting selectedDateModifier + // (the Custom date/range sub-view) back to the top menu on return. Persist it and restore it on return (a + // pending year write-back for a search calendar) so the calendar reopens with the picked year applied. + const [storedDateModifier] = useOnyx(ONYXKEYS.CALENDAR_PICKER_SELECTED_DATE_MODIFIER); + const [storedYearSelection] = useOnyx(ONYXKEYS.CALENDAR_PICKER_SELECTED_YEAR); + const hasRestoredDateModifierRef = useRef(false); + + useEffect(() => { + if (!selectedDateModifier) { + return; + } + setCalendarPickerSelectedDateModifier(selectedDateModifier); + }, [selectedDateModifier]); + + useEffect(() => { + if (hasRestoredDateModifierRef.current || selectedDateModifier || !storedDateModifier || !storedYearSelection?.contextID.startsWith('search')) { + return; + } + const dateModifierToRestore = Object.values(CONST.SEARCH.DATE_MODIFIERS).find((modifier) => modifier === storedDateModifier); + if (!dateModifierToRestore) { + return; + } + hasRestoredDateModifierRef.current = true; + requestAnimationFrame(() => setSelectedDateModifier(dateModifierToRestore)); + }, [storedDateModifier, storedYearSelection, selectedDateModifier]); + const getDateFormValues = (dateValues: SearchDateValues) => { const dateFormValues: Record = {}; dateFormValues[`${filterKey}On`] = dateValues[CONST.SEARCH.DATE_MODIFIERS.ON]; diff --git a/src/components/Search/FilterDropdowns/AdvancedFilters/AdvancedFiltersPopup/index.tsx b/src/components/Search/FilterDropdowns/AdvancedFilters/AdvancedFiltersPopup/index.tsx index 28a9246bedcb..f8a9cfe05b85 100644 --- a/src/components/Search/FilterDropdowns/AdvancedFilters/AdvancedFiltersPopup/index.tsx +++ b/src/components/Search/FilterDropdowns/AdvancedFilters/AdvancedFiltersPopup/index.tsx @@ -1,4 +1,4 @@ -import React, {useRef, useState} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import SafeTriangle from '@components/SafeTriangle'; import FilterContent from '@components/Search/FilterComponents/AdvancedFilters/FilterContent'; @@ -25,6 +25,19 @@ function AdvancedFiltersPopup({queryJSON}: AdvancedFiltersPopupProps) { const [selectedFilter, setSelectedFilter] = useState(CONST.SEARCH.SYNTAX_FILTER_KEYS.TYPE); const filterContentRef = useRef(null); const [searchAdvancedFiltersForm] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM); + const [storedYearSelection] = useOnyx(ONYXKEYS.CALENDAR_PICKER_SELECTED_YEAR); + const hasRestoredFilterRef = useRef(false); + + // The year picker is only reached from the Date filter; opening it blurs the Search screen and unmounts + // this popover, which would otherwise remount reset to the default Type filter. When returning (a pending + // year write-back for a search calendar), reopen to the Date filter so the user lands back on the calendar. + useEffect(() => { + if (hasRestoredFilterRef.current || !storedYearSelection?.contextID.startsWith('search')) { + return; + } + hasRestoredFilterRef.current = true; + requestAnimationFrame(() => setSelectedFilter(CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE)); + }, [storedYearSelection]); const {updateFilterQueryParams} = useUpdateFilterQuery(queryJSON); diff --git a/src/components/Search/FilterDropdowns/DateSelectPopup/index.tsx b/src/components/Search/FilterDropdowns/DateSelectPopup/index.tsx index 05125a150024..ab306427de30 100644 --- a/src/components/Search/FilterDropdowns/DateSelectPopup/index.tsx +++ b/src/components/Search/FilterDropdowns/DateSelectPopup/index.tsx @@ -9,14 +9,17 @@ import ActionButtons from '@components/Search/FilterDropdowns/ActionButtons'; import type {SearchDatePreset} from '@components/Search/types'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; +import {setCalendarPickerSelectedDateModifier} from '@libs/actions/CalendarPicker'; import getPlatform from '@libs/getPlatform'; import type {SearchDateValues} from '@libs/SearchQueryUtils'; import {getDateModifierTitle, getDateRangeDisplayValueFromFormValue} from '@libs/SearchQueryUtils'; import type {SearchDateModifier} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import SelectedDateModifierHeader from './SelectedDateModifierHeader'; type DateSelectPopupProps = { @@ -54,6 +57,32 @@ function DateSelectPopup({label, value, presets, style, closeOverlay, onChange, const scrollViewRef = useRef>(null); const [selectedDateModifier, setSelectedDateModifier] = useState(null); const [shouldShowRangeError, setShouldShowRangeError] = useState(false); + + // Opening the year picker blurs the Search screen and unmounts this popover, resetting selectedDateModifier + // (the Custom date/range sub-view) back to the top menu on return. Persist it and restore it on return (a + // pending year write-back for a search calendar) so the calendar reopens with the picked year applied. + const [storedDateModifier] = useOnyx(ONYXKEYS.CALENDAR_PICKER_SELECTED_DATE_MODIFIER); + const [storedYearSelection] = useOnyx(ONYXKEYS.CALENDAR_PICKER_SELECTED_YEAR); + const hasRestoredDateModifierRef = useRef(false); + + useEffect(() => { + if (!selectedDateModifier) { + return; + } + setCalendarPickerSelectedDateModifier(selectedDateModifier); + }, [selectedDateModifier]); + + useEffect(() => { + if (hasRestoredDateModifierRef.current || selectedDateModifier || !storedDateModifier || !storedYearSelection?.contextID.startsWith('search')) { + return; + } + const dateModifierToRestore = Object.values(CONST.SEARCH.DATE_MODIFIERS).find((modifier) => modifier === storedDateModifier); + if (!dateModifierToRestore) { + return; + } + hasRestoredDateModifierRef.current = true; + requestAnimationFrame(() => setSelectedDateModifier(dateModifierToRestore)); + }, [storedDateModifier, storedYearSelection, selectedDateModifier]); const [rangeText, setRangeText] = useState(() => getDateRangeDisplayValueFromFormValue(value[CONST.SEARCH.DATE_MODIFIERS.RANGE], value[CONST.SEARCH.DATE_MODIFIERS.AFTER], value[CONST.SEARCH.DATE_MODIFIERS.BEFORE]), ); diff --git a/src/libs/actions/CalendarPicker.ts b/src/libs/actions/CalendarPicker.ts index 11dc03bbd751..69e783857196 100644 --- a/src/libs/actions/CalendarPicker.ts +++ b/src/libs/actions/CalendarPicker.ts @@ -14,4 +14,12 @@ function clearCalendarPickerSelectedYear() { Onyx.set(ONYXKEYS.CALENDAR_PICKER_SELECTED_YEAR, null); } -export {setCalendarPickerSelectedYear, clearCalendarPickerSelectedYear}; +/** + * Stores the active Search date-filter sub-view (e.g. a Custom date/range modifier) so the date filter + * can restore it when the popover remounts after the year picker screen blurs and unmounts it. + */ +function setCalendarPickerSelectedDateModifier(selectedDateModifier: string) { + Onyx.set(ONYXKEYS.CALENDAR_PICKER_SELECTED_DATE_MODIFIER, selectedDateModifier); +} + +export {setCalendarPickerSelectedYear, clearCalendarPickerSelectedYear, setCalendarPickerSelectedDateModifier}; From d62b9dbf71296c2934f0fd28931ce987e918274f Mon Sep 17 00:00:00 2001 From: Tom Date: Sat, 13 Jun 2026 08:34:24 -0700 Subject: [PATCH 12/22] DOB date picker: keep popover mounted + hide CalendarPicker while year selector is open Per the approach agreed with the dynamic-routes maintainer for the date picker modal: on wide-screen web, keep the DatePickerModal popover mounted (don't toggle isVisible off) while the @react-navigation year-selector RHP is focused, and have CalendarPicker hide itself (opacity:0 + pointerEvents:none) so its z-index-9996 portal doesn't paint over the RHP. This preserves context and avoids the close/re-open flicker. Narrow/native still dismiss via closeTop()+Onyx handoff (shouldCloseModalOnYearPickerOpen). --- src/components/DatePicker/CalendarPicker/index.tsx | 13 ++++++++++++- src/components/DatePicker/DatePickerModal.tsx | 2 +- tests/unit/CalendarPickerTest.tsx | 7 +++++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/components/DatePicker/CalendarPicker/index.tsx b/src/components/DatePicker/CalendarPicker/index.tsx index a5aa5dadf7d9..2cc12b8aa05a 100644 --- a/src/components/DatePicker/CalendarPicker/index.tsx +++ b/src/components/DatePicker/CalendarPicker/index.tsx @@ -4,6 +4,7 @@ import React, {useCallback, useEffect, useRef, useState} from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; +import useIsYearSelectorOpen from '@components/DatePicker/useIsYearSelectorOpen'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; import Text from '@components/Text'; @@ -14,6 +15,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {clearCalendarPickerSelectedYear} from '@libs/actions/CalendarPicker'; import {closeTop} from '@libs/actions/Modal'; import DateUtils from '@libs/DateUtils'; +import getPlatform from '@libs/getPlatform'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import CONST from '@src/CONST'; @@ -120,6 +122,12 @@ function CalendarPicker({ const minYear = CONST.CALENDAR_PICKER.MIN_YEAR; const maxYear = CONST.CALENDAR_PICKER.MAX_YEAR; + const isYearSelectorOpen = useIsYearSelectorOpen(); + // On wide-screen web the date popover stays mounted while the @react-navigation year-selector RHP is open + // (so the picker context is preserved); hide this CalendarPicker — a z-index-9996 portal that would otherwise + // paint over the RHP — and disable its pointer events until the user returns. Narrow/native dismiss the host instead. + const shouldHideForYearSelector = getPlatform() === CONST.PLATFORM.WEB && !isSmallScreenWidth && isYearSelectorOpen; + // When the year picker screen writes back a selection for this CalendarPicker instance, // apply it to the displayed date and clear the transient result so it isn't re-applied. useEffect(() => { @@ -241,7 +249,10 @@ function CalendarPicker({ const getAccessibilityState = useCallback((isSelected: boolean) => ({selected: isSelected}), []); return ( - + ({ createNavigationContainerRef: jest.fn(), })); +// CalendarPicker reads useRootNavigationState (via useIsYearSelectorOpen); the bare navigationRef mock above +// has no isReady(), so stub the hook to resolve its selector against an undefined navigation state. +jest.mock('@hooks/useRootNavigationState', () => ({ + __esModule: true, + default: (selector: (state: undefined) => unknown) => selector(undefined), +})); + jest.mock('../../src/hooks/useLocalize', () => jest.fn(() => ({ translate: jest.fn(), From 942fc3b84bdd85687e39f7565876e34cf405ca15 Mon Sep 17 00:00:00 2001 From: Tom Date: Sat, 20 Jun 2026 08:24:35 -0700 Subject: [PATCH 13/22] Make kept-mounted date popovers pointer-transparent for the year-selector RHP The migrated dynamic year-selector renders as an RHP over the kept-mounted search filter / date popovers. Those popovers are react-native-web Modals whose full-screen portal root (ModalAnimation: position:fixed, inset 0, z-index 9996) has pointer-events:auto and swallowed the clicks meant for the RHP, so years were not selectable. RNW only forwards zIndex to that root, and dropping coverScreen to avoid it remounts the popover (losing its state / flashing back to the Type menu). Add a shouldDisablePointerEvents prop through BaseModal to ReanimatedModal that toggles pointer-events:none on the RNW portal root while keeping the modal mounted, so the popover's state survives the round-trip and the RHP receives its clicks. Wire it (plus dropping the backdrop and hiding the frame) into the search AdvancedFilters popover and the DatePickerModal date popover, gated to desktop web while the year selector is open. --- src/components/DatePicker/DatePickerModal.tsx | 11 +++- src/components/Modal/BaseModal.tsx | 2 + .../Modal/ReanimatedModal/index.tsx | 46 ++++++++++++++- src/components/Modal/ReanimatedModal/types.ts | 8 +++ .../FilterDropdowns/FilterPopupButton.tsx | 28 ++++++++- .../SearchAdvancedFiltersPopup/index.tsx | 59 +++++++++++++------ 6 files changed, 132 insertions(+), 22 deletions(-) diff --git a/src/components/DatePicker/DatePickerModal.tsx b/src/components/DatePicker/DatePickerModal.tsx index 665018cbae65..d62e38096768 100644 --- a/src/components/DatePicker/DatePickerModal.tsx +++ b/src/components/DatePicker/DatePickerModal.tsx @@ -50,6 +50,10 @@ function DatePickerModal({ const {isSmallScreenWidth} = useResponsiveLayout(); const isDesktopWeb = getPlatform() === CONST.PLATFORM.WEB && !isSmallScreenWidth; const isYearSelectorOpen = useIsYearSelectorOpen(); + // On desktop web the date popover stays mounted while the year-selector RHP is open (so the picked year is + // applied on return). Hide its frame and make the whole modal subtree pointer-transparent so the RHP renders + // clean and its years are clickable — the inner CalendarPicker already self-hides the same way. + const shouldHideForYearSelector = isDesktopWeb && isYearSelectorOpen; useEffect(() => { if (shouldSaveDraft && formID) { @@ -79,7 +83,12 @@ function DatePickerModal({ // Suppress the popstate close while the year selector is open; selecting a year does a goBack (history // change) and would otherwise tear this host down instead of returning to it with the new year applied. shouldCloseWhenBrowserNavigationChanged={shouldCloseWhenBrowserNavigationChanged && !isYearSelectorOpen} - innerContainerStyle={isSmallScreenWidth ? styles.w100 : {width: CONST.POPOVER_DATE_WIDTH}} + hasBackdrop={!shouldHideForYearSelector} + shouldDisablePointerEvents={shouldHideForYearSelector} + innerContainerStyle={{ + ...(isSmallScreenWidth ? styles.w100 : {width: CONST.POPOVER_DATE_WIDTH}), + ...(shouldHideForYearSelector ? {opacity: 0, pointerEvents: 'none'} : {}), + }} anchorAlignment={anchorAlignment} restoreFocusType={CONST.MODAL.RESTORE_FOCUS_TYPE.DELETE} shouldSwitchPositionIfOverflow diff --git a/src/components/Modal/BaseModal.tsx b/src/components/Modal/BaseModal.tsx index b9bf1acacdc8..087ee00e3d09 100644 --- a/src/components/Modal/BaseModal.tsx +++ b/src/components/Modal/BaseModal.tsx @@ -68,6 +68,7 @@ function BaseModal({ shouldApplySidePanelOffset = type === CONST.MODAL.MODAL_TYPE.RIGHT_DOCKED, hasBackdrop, backdropOpacity, + shouldDisablePointerEvents = false, shouldDisableBottomSafeAreaPadding = false, shouldIgnoreBackHandlerDuringTransition = false, forwardedFSClass = CONST.FULLSTORY.CLASS.UNMASK, @@ -369,6 +370,7 @@ function BaseModal({ backdropOpacity={backdropOpacityAdjusted} backdropTransitionOutTiming={0} hasBackdrop={hasBackdrop ?? fullscreen} + shouldDisablePointerEvents={shouldDisablePointerEvents} coverScreen={fullscreen} style={modalStyle} deviceHeight={windowHeight} diff --git a/src/components/Modal/ReanimatedModal/index.tsx b/src/components/Modal/ReanimatedModal/index.tsx index f162224c7112..a65e177132ca 100644 --- a/src/components/Modal/ReanimatedModal/index.tsx +++ b/src/components/Modal/ReanimatedModal/index.tsx @@ -14,10 +14,14 @@ import TransitionTracker from '@libs/Navigation/TransitionTracker'; import type {TransitionHandle} from '@libs/Navigation/TransitionTracker'; import variables from '@styles/variables'; import CONST from '@src/CONST'; +import viewRef from '@src/types/utils/viewRef'; import Backdrop from './Backdrop'; import Container from './Container'; import type ReanimatedModalProps from './types'; +// Zero-footprint sentinel used only to locate the RNW Modal portal root for the pointer-events toggle. +const POINTER_EVENTS_PROBE_STYLE = {position: 'absolute', width: 0, height: 0} as const; + function ReanimatedModal({ testID, animationInDelay, @@ -29,6 +33,7 @@ function ReanimatedModal({ coverScreen = true, children, hasBackdrop = true, + shouldDisablePointerEvents = false, backdropColor = 'black', backdropOpacity = variables.overlayOpacity, customBackdrop = null, @@ -61,6 +66,9 @@ function ReanimatedModal({ const backHandlerListener = useRef(null); const handleRef = useRef(undefined); const transitionHandleRef = useRef(null); + // Web-only: a zero-size sentinel used to locate the RNW Modal's full-screen portal root (ModalAnimation) so + // its pointer-events can be toggled when shouldDisablePointerEvents is set. See the effect below. + const pointerEventsProbeRef = useRef(null); const styles = useThemeStyles(); @@ -182,6 +190,37 @@ function ReanimatedModal({ return {zIndex: StyleSheet.flatten(style)?.zIndex}; }, [style]); + // react-native-web's renders its own full-screen portal root (ModalAnimation: position:fixed, inset 0, + // z-index 9996, pointer-events:auto) plus a full-screen ModalContent View. RNW only forwards `zIndex` to that + // root, so there is no prop to make it pointer-transparent. When `shouldDisablePointerEvents` is set, a route/RHP + // (the dynamic year selector) is intentionally shown over this kept-mounted popover and must receive clicks, but + // those full-screen wrappers would otherwise swallow them. We keep the popover mounted (so its state survives the + // round-trip — no remount/reset) and instead toggle pointer-events on the portal root imperatively. The popover + // content is already visually hidden by the caller, so disabling pointer events on the whole subtree is safe. + useEffect(() => { + if (getPlatform() !== CONST.PLATFORM.WEB) { + return; + } + const probe = pointerEventsProbeRef.current; + if (!(probe instanceof HTMLElement)) { + return; + } + // The outermost fixed-position ancestor of the probe is the RNW Modal portal root (ModalAnimation). + let portalRoot: HTMLElement | null = null; + for (let node = probe.parentElement; node && node !== document.body; node = node.parentElement) { + if (window.getComputedStyle(node).position === 'fixed') { + portalRoot = node; + } + } + if (!portalRoot) { + return; + } + portalRoot.style.pointerEvents = shouldDisablePointerEvents ? 'none' : ''; + return () => { + portalRoot.style.pointerEvents = ''; + }; + }, [shouldDisablePointerEvents, isVisibleState]); + const containerView = ( {hasBackdrop && backdropView} @@ -245,6 +284,11 @@ function ReanimatedModal({ style={modalStyle} {...props} > + {isBackdropMounted && hasBackdrop && backdropView} {avoidKeyboard ? (