From db5ad4666192e96c54dc9504435bf1b6119d6e52 Mon Sep 17 00:00:00 2001 From: "Kevin Brian Bader (via MelvinBot)" Date: Wed, 30 Sep 2026 20:50:20 +0000 Subject: [PATCH 1/2] List upcoming years ascending after the pinned year in the year picker Co-authored-by: Kevin Brian Bader --- .../CalendarPicker/YearPickerModal.tsx | 15 +++++++- tests/ui/YearPickerModalTest.tsx | 38 ++++++++++++++++--- 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/src/components/DatePicker/CalendarPicker/YearPickerModal.tsx b/src/components/DatePicker/CalendarPicker/YearPickerModal.tsx index c98ec9f3ad10..5ab244fd5515 100644 --- a/src/components/DatePicker/CalendarPicker/YearPickerModal.tsx +++ b/src/components/DatePicker/CalendarPicker/YearPickerModal.tsx @@ -39,7 +39,20 @@ function YearPickerModal({isVisible, years, currentYear, onYearChange, onClose, const initialYear = useInitialSelection(resolvedCurrentYear, {isVisible}); // Pin the frozen initial year to the top of the full sorted list before search filtering, so it stays pinned while searching. // Copy before sorting so we don't mutate the caller's `years` prop during render. - const sortedYears = [...years].sort((a, b) => b.value - a.value); + // Long lists (where the pin applies) show upcoming years ascending, then past years nearest first, so the row after the pinned year is the next year. + // Short lists aren't pinned, so they keep the newest-first order. + const shouldSortAroundInitialYear = years.length >= CONST.STANDARD_LIST_ITEM_LIMIT; + const sortedYears = [...years].sort((a, b) => { + if (!shouldSortAroundInitialYear) { + return b.value - a.value; + } + const isAUpcoming = a.value > initialYear; + const isBUpcoming = b.value > initialYear; + if (isAUpcoming !== isBUpcoming) { + return isAUpcoming ? -1 : 1; + } + return isAUpcoming ? a.value - b.value : b.value - a.value; + }); const orderedYears = moveInitialSelectionToTop(sortedYears, [String(initialYear)]); const data = searchText === '' ? orderedYears : orderedYears.filter((year) => year.text?.includes(searchText)); const headerMessage = !data.length ? translate('common.noResultsFound') : ''; diff --git a/tests/ui/YearPickerModalTest.tsx b/tests/ui/YearPickerModalTest.tsx index c35f5c1b1167..c9de48b7924e 100644 --- a/tests/ui/YearPickerModalTest.tsx +++ b/tests/ui/YearPickerModalTest.tsx @@ -50,6 +50,7 @@ describe('YearPickerModal', () => { }); it('pins the current year to the top on open', () => { + // Given a year list long enough for the pin to apply render( { />, ); + // When the picker opens const props = getSelectionListProps(); + + // Then the current year leads the list and is the focused item expect(props?.data.at(0)?.value).toBe(2020); - // The list sorts newest-first, so 2027 would lead if nothing were pinned. - expect(props?.data.at(0)?.value).not.toBe(2027); expect(props?.initiallyFocusedItemKey).toBe('2020'); expect(props?.shouldScrollToFocusedIndexOnMount).toBe(false); expect(props?.shouldUpdateFocusedIndex).toBe(true); }); + it('lists upcoming years ascending, then past years nearest first, after the pinned year', () => { + // Given a year list long enough for the pin to apply, with 2020 selected + render( + , + ); + + // When the picker opens + const values = getSelectionListProps()?.data.map((year) => year.value); + + // Then the row after the pinned year is the next year, not the latest year in the list + expect(values).toEqual([2020, 2021, 2022, 2023, 2024, 2025, 2026, 2027, 2019, 2018, 2017, 2016, 2015, 2014]); + }); + it('keeps the current year pinned at the top of the search results', () => { + // Given a year list long enough for the pin to apply, with 2020 selected render( { />, ); - // "202" matches 2020-2027. 2027 sorts first, so 2020 leading proves the pin held. + // When the user searches "202", which matches 2020-2027 act(() => { getSelectionListProps()?.textInputOptions?.onChangeText?.('202'); }); - expect(getSelectionListProps()?.data.at(0)?.value).toBe(2020); + // Then 2020 stays first and the upcoming years follow in ascending order + expect(getSelectionListProps()?.data.map((year) => year.value)).toEqual([2020, 2021, 2022, 2023, 2024, 2025, 2026, 2027]); }); it('does not reorder when the year list is under the item-limit threshold', () => { + // Given a year list shorter than the item-limit threshold render( { />, ); - // Below the threshold moveInitialSelectionToTop is a no-op, so newest-first order is kept. - expect(getSelectionListProps()?.data.at(0)?.value).toBe(2024); + // When the picker opens + const values = getSelectionListProps()?.data.map((year) => year.value); + + // Then nothing is pinned and the newest-first order is kept + expect(values).toEqual([2024, 2023, 2022, 2021, 2020]); }); }); From 6b6f78cfce0332bdb30a8ab772618727103724cf Mon Sep 17 00:00:00 2001 From: "Kevin Brian Bader (via MelvinBot)" Date: Fri, 2 Oct 2026 00:06:40 +0000 Subject: [PATCH 2/2] Share the pin threshold check and cover search and edge-of-list year ordering Co-authored-by: Kevin Brian Bader --- .../CalendarPicker/YearPickerModal.tsx | 12 +++---- src/libs/SelectionListOrderUtils.ts | 8 ++++- tests/ui/YearPickerModalTest.tsx | 31 ++++++++++++++++--- tests/unit/SelectionListOrderUtilsTest.ts | 13 +++++++- 4 files changed, 51 insertions(+), 13 deletions(-) diff --git a/src/components/DatePicker/CalendarPicker/YearPickerModal.tsx b/src/components/DatePicker/CalendarPicker/YearPickerModal.tsx index 5ab244fd5515..0672715a273a 100644 --- a/src/components/DatePicker/CalendarPicker/YearPickerModal.tsx +++ b/src/components/DatePicker/CalendarPicker/YearPickerModal.tsx @@ -8,7 +8,7 @@ import useInitialSelection from '@hooks/useInitialSelection'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import moveInitialSelectionToTop from '@libs/SelectionListOrderUtils'; +import moveInitialSelectionToTop, {shouldMoveInitialSelectionToTop} from '@libs/SelectionListOrderUtils'; import CONST from '@src/CONST'; @@ -41,18 +41,16 @@ function YearPickerModal({isVisible, years, currentYear, onYearChange, onClose, // Copy before sorting so we don't mutate the caller's `years` prop during render. // Long lists (where the pin applies) show upcoming years ascending, then past years nearest first, so the row after the pinned year is the next year. // Short lists aren't pinned, so they keep the newest-first order. - const shouldSortAroundInitialYear = years.length >= CONST.STANDARD_LIST_ITEM_LIMIT; - const sortedYears = [...years].sort((a, b) => { - if (!shouldSortAroundInitialYear) { - return b.value - a.value; - } + const compareNewestFirst = (a: CalendarPickerListItem, b: CalendarPickerListItem) => b.value - a.value; + const compareAroundInitialYear = (a: CalendarPickerListItem, b: CalendarPickerListItem) => { const isAUpcoming = a.value > initialYear; const isBUpcoming = b.value > initialYear; if (isAUpcoming !== isBUpcoming) { return isAUpcoming ? -1 : 1; } return isAUpcoming ? a.value - b.value : b.value - a.value; - }); + }; + const sortedYears = [...years].sort(shouldMoveInitialSelectionToTop(years.length) ? compareAroundInitialYear : compareNewestFirst); const orderedYears = moveInitialSelectionToTop(sortedYears, [String(initialYear)]); const data = searchText === '' ? orderedYears : orderedYears.filter((year) => year.text?.includes(searchText)); const headerMessage = !data.length ? translate('common.noResultsFound') : ''; diff --git a/src/libs/SelectionListOrderUtils.ts b/src/libs/SelectionListOrderUtils.ts index a5f9777dbe52..e9c2918cce46 100644 --- a/src/libs/SelectionListOrderUtils.ts +++ b/src/libs/SelectionListOrderUtils.ts @@ -1,7 +1,12 @@ import CONST from '@src/CONST'; +/** Whether a list of `itemCount` items is long enough for the initial selection to be pinned to the top. */ +function shouldMoveInitialSelectionToTop(itemCount: number): boolean { + return itemCount >= CONST.STANDARD_LIST_ITEM_LIMIT; +} + function moveInitialSelectionToTop(items: T[], initialSelectedValues: string[]): T[] { - if (initialSelectedValues.length === 0 || items.length < CONST.STANDARD_LIST_ITEM_LIMIT) { + if (initialSelectedValues.length === 0 || !shouldMoveInitialSelectionToTop(items.length)) { return items; } @@ -22,3 +27,4 @@ function moveInitialSelectionToTop(items: T } export default moveInitialSelectionToTop; +export {shouldMoveInitialSelectionToTop}; diff --git a/tests/ui/YearPickerModalTest.tsx b/tests/ui/YearPickerModalTest.tsx index c9de48b7924e..cc45b7720e29 100644 --- a/tests/ui/YearPickerModalTest.tsx +++ b/tests/ui/YearPickerModalTest.tsx @@ -88,13 +88,36 @@ describe('YearPickerModal', () => { expect(values).toEqual([2020, 2021, 2022, 2023, 2024, 2025, 2026, 2027, 2019, 2018, 2017, 2016, 2015, 2014]); }); + it.each([ + // The newest year is selected, so there are no upcoming years and only past years follow, nearest first + [2027, [2027, 2026, 2025, 2024, 2023, 2022, 2021, 2020, 2019, 2018, 2017, 2016, 2015, 2014]], + // The oldest year is selected, so there are no past years and only upcoming years follow, ascending + [2014, [2014, 2015, 2016, 2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024, 2025, 2026, 2027]], + ])('orders correctly when %i is at the edge of the list', (currentYear, expected) => { + // Given a year list long enough for the pin to apply, with a year at one end of the list selected + render( + , + ); + + // When the picker opens + const values = getSelectionListProps()?.data.map((year) => year.value); + + // Then the selected year leads and the rest of the list is ordered from the only side that has years + expect(values).toEqual(expected); + }); + it('keeps the current year pinned at the top of the search results', () => { - // Given a year list long enough for the pin to apply, with 2020 selected + // Given a year list long enough for the pin to apply, with 2024 selected so "202" matches years on both sides of it render( , ); @@ -104,8 +127,8 @@ describe('YearPickerModal', () => { getSelectionListProps()?.textInputOptions?.onChangeText?.('202'); }); - // Then 2020 stays first and the upcoming years follow in ascending order - expect(getSelectionListProps()?.data.map((year) => year.value)).toEqual([2020, 2021, 2022, 2023, 2024, 2025, 2026, 2027]); + // Then 2024 stays first, the upcoming years follow in ascending order, and the past years come last, nearest first + expect(getSelectionListProps()?.data.map((year) => year.value)).toEqual([2024, 2025, 2026, 2027, 2023, 2022, 2021, 2020]); }); it('does not reorder when the year list is under the item-limit threshold', () => { diff --git a/tests/unit/SelectionListOrderUtilsTest.ts b/tests/unit/SelectionListOrderUtilsTest.ts index 6f013ac12b4b..8b8f9d585fa9 100644 --- a/tests/unit/SelectionListOrderUtilsTest.ts +++ b/tests/unit/SelectionListOrderUtilsTest.ts @@ -1,4 +1,4 @@ -import moveInitialSelectionToTop from '@libs/SelectionListOrderUtils'; +import moveInitialSelectionToTop, {shouldMoveInitialSelectionToTop} from '@libs/SelectionListOrderUtils'; import CONST from '@src/CONST'; @@ -86,4 +86,15 @@ describe('SelectionListOrderUtils', () => { expect(valuesOf(moveInitialSelectionToTop(items, valuesOf(items)))).toEqual(valuesOf(items)); }); }); + + describe('shouldMoveInitialSelectionToTop', () => { + it('applies the pin only once the list reaches the global threshold (boundary is inclusive)', () => { + // Given item counts just below, at, and above the threshold + // When checking whether the pin applies to each + // Then only the counts at or above the threshold pin, matching moveInitialSelectionToTop's early return + expect(shouldMoveInitialSelectionToTop(CONST.STANDARD_LIST_ITEM_LIMIT - 1)).toBe(false); + expect(shouldMoveInitialSelectionToTop(CONST.STANDARD_LIST_ITEM_LIMIT)).toBe(true); + expect(shouldMoveInitialSelectionToTop(CONST.STANDARD_LIST_ITEM_LIMIT + 1)).toBe(true); + }); + }); });