Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
c306f00
feat: allow typing a date into the date field, one segment at a time
Krishna2323 Sep 17, 2026
79a94e8
fix: reach the overlay-free popover from a narrow pane, and open the …
Krishna2323 Sep 17, 2026
de3a085
fix: keep the date mask a fixed width and track the caret per digit
Krishna2323 Sep 17, 2026
59e4711
fix: match the prototype's segment rules for typed dates
Krishna2323 Sep 17, 2026
0d46d4f
fix: do not drag the calendar to the date limit while a year is typed
Krishna2323 Sep 17, 2026
b13855f
fix: fill the month and day from the right so a single digit shows as 02
Krishna2323 Sep 17, 2026
b9896d6
fix: send a click on the field's empty space to the first unfilled se…
Krishna2323 Sep 17, 2026
905605a
fix: hide the calendar icon once a digit is typed
Krishna2323 Sep 17, 2026
7d4f9c6
fix: keep the typing calendar attached to its field while the page sc…
Krishna2323 Sep 17, 2026
fc6df11
fix: keep the calendar open while the year or month picker is used
Krishna2323 Sep 17, 2026
6010595
refactor: remove the workarounds that accumulated around the typed da…
Krishna2323 Sep 17, 2026
cf9bbd1
feat: update the date field when a month or year is picked
Krishna2323 Sep 17, 2026
6848a74
fix: stop a stray caret report moving the date field's active segment
Krishna2323 Sep 17, 2026
c534524
refactor: rebuild the date field as one input per segment
Krishna2323 Sep 18, 2026
8f9b152
fix: measure each date segment instead of estimating its width
Krishna2323 Sep 18, 2026
1f44f08
fix: show the committed date when the date field is not being edited
Krishna2323 Sep 18, 2026
d3068c3
fix: let a press land on the date segment it was aimed at
Krishna2323 Sep 18, 2026
1d90b46
fix: stop the date field's props being dropped by the segmented input
Krishna2323 Sep 18, 2026
996c988
fix: land the caret at the end of a date segment reached by keystroke
Krishna2323 Sep 18, 2026
62993b7
fix: reposition the calendar when the date field changes height
Krishna2323 Sep 18, 2026
aa094c3
fix: keep a committed date from restarting the segment being typed
Krishna2323 Sep 18, 2026
6970cff
fix: drop a date segment keystroke that cannot complete a leading zero
Krishna2323 Sep 18, 2026
c763339
feat: keep the unreached mask letters on screen while a date is typed
Krishna2323 Sep 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion src/components/DatePicker/CalendarPicker/MonthPickerModal.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import Modal from '@components/Modal';
import {usePopoverActions} from '@components/PopoverProvider';
import ScreenWrapper from '@components/ScreenWrapper';
import SelectionList from '@components/SelectionList';
import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem';
Expand All @@ -11,7 +12,9 @@ import DateUtils from '@libs/DateUtils';

import CONST from '@src/CONST';

import React, {useEffect, useMemo, useState} from 'react';
import type {View} from 'react-native';

import React, {useEffect, useMemo, useRef, useState} from 'react';
import {Keyboard} from 'react-native';

type MonthPickerModalProps = {
Expand All @@ -32,9 +35,21 @@ type MonthPickerModalProps = {
function MonthPickerModal({isVisible, currentMonth = new Date().getMonth(), onMonthChange, onClose, shouldEnableBackdropInNarrowPane = false}: MonthPickerModalProps) {
const styles = useThemeStyles();
const {translate, dateFnsLocale} = useLocalize();
const {setActivePopoverExtraAnchorRef} = usePopoverActions();
const contentRef = useRef<View>(null);
const [searchText, setSearchText] = useState('');
const monthNames = DateUtils.getMonthNames(dateFnsLocale);

useEffect(() => {
if (!isVisible) {
return;
}

// This modal is rendered above the calendar rather than inside it, so a press on a month counts as a press
// outside the calendar popover and would dismiss it. Registering the content keeps the calendar open.
setActivePopoverExtraAnchorRef(contentRef);
}, [isVisible, setActivePopoverExtraAnchorRef]);

const allMonths = useMemo(() => DateUtils.getFilteredMonthItems(monthNames, currentMonth), [monthNames, currentMonth]);

const {data, headerMessage} = useMemo(() => {
Expand Down Expand Up @@ -75,6 +90,7 @@ function MonthPickerModal({isVisible, currentMonth = new Date().getMonth(), onMo
enableEdgeToEdgeBottomSafeAreaPadding
>
<ScreenWrapper
ref={contentRef}
style={[styles.pb0]}
includePaddingTop={false}
enableEdgeToEdgeBottomSafeAreaPadding
Expand Down
18 changes: 17 additions & 1 deletion src/components/DatePicker/CalendarPicker/YearPickerModal.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import Modal from '@components/Modal';
import {usePopoverActions} from '@components/PopoverProvider';
import ScreenWrapper from '@components/ScreenWrapper';
import SelectionList from '@components/SelectionList';
import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem';
Expand All @@ -9,7 +10,9 @@ import useThemeStyles from '@hooks/useThemeStyles';

import CONST from '@src/CONST';

import React, {useEffect, useMemo, useState} from 'react';
import type {View} from 'react-native';

import React, {useEffect, useMemo, useRef, useState} from 'react';
import {Keyboard} from 'react-native';

import type CalendarPickerListItem from './types';
Expand All @@ -30,7 +33,19 @@ type YearPickerModalProps = {
function YearPickerModal({isVisible, years, currentYear = new Date().getFullYear(), onYearChange, onClose, shouldEnableBackdropInNarrowPane = false}: YearPickerModalProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const {setActivePopoverExtraAnchorRef} = usePopoverActions();
const contentRef = useRef<View>(null);
const [searchText, setSearchText] = useState('');

useEffect(() => {
if (!isVisible) {
return;
}

// This modal is rendered above the calendar rather than inside it, so a press on a year counts as a press
// outside the calendar popover and would dismiss it. Registering the content keeps the calendar open.
setActivePopoverExtraAnchorRef(contentRef);
}, [isVisible, setActivePopoverExtraAnchorRef]);
const {data, headerMessage} = useMemo(() => {
const yearsList = searchText === '' ? years : years.filter((year) => year.text?.includes(searchText));
return {
Expand Down Expand Up @@ -71,6 +86,7 @@ function YearPickerModal({isVisible, years, currentYear = new Date().getFullYear
enableEdgeToEdgeBottomSafeAreaPadding
>
<ScreenWrapper
ref={contentRef}
style={[styles.pb0]}
includePaddingTop={false}
enableEdgeToEdgeBottomSafeAreaPadding
Expand Down
58 changes: 46 additions & 12 deletions src/components/DatePicker/CalendarPicker/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import CONST from '@src/CONST';

import type {StyleProp, ViewStyle} from 'react-native';

import {addMonths, addYears, format, isSameDay, parseISO, setDate, setMonth, setYear, startOfDay, subMonths, subYears} from 'date-fns';
import {addMonths, addYears, format, getDaysInMonth, 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 {View} from 'react-native';
Expand Down Expand Up @@ -44,6 +44,12 @@ type CalendarPickerProps = {

onSelected?: (selectedDate: string) => void;

/**
* Called when a month or year is picked from its own picker, with the date that pick lands on. The selection is
* not finished, so the calendar stays open for the day press. Leave it out to have those picks only move the view.
*/
onMonthOrYearSelected?: (selectedDate: string) => void;

/** Optional style override for the header container */
headerContainerStyle?: StyleProp<ViewStyle>;

Expand All @@ -52,6 +58,15 @@ type CalendarPickerProps = {

/** Whether Month/Year right-docked picker modals should keep backdrop in narrow pane context */
shouldEnableMonthYearBackdropInNarrowPane?: boolean;

/**
* Moves the calendar to this month without selecting a day, so it can follow a date being typed into the input.
* The calendar still owns its own view, so its arrows and month picker keep working between updates.
*/
viewDate?: Date;

/** Changes every time `viewDate` is asserted, including when it repeats the month the calendar already shows */
viewDateVersion?: number;
};

function getInitialCurrentDateView(value: Date | string, minDate: Date, maxDate: Date) {
Expand All @@ -75,16 +90,25 @@ function getInitialCurrentDateView(value: Date | string, minDate: Date, maxDate:
return initialCurrentDateView;
}

// Keeps the day inside the target month, since setYear alone turns February 29 into March 1 on a non leap year
function setYearKeepingDay(date: Date, year: number) {
const firstOfTargetMonth = setYear(setDate(date, 1), year);
return setDate(firstOfTargetMonth, Math.min(date.getDate(), getDaysInMonth(firstOfTargetMonth)));
}

function CalendarPicker({
value = new Date(),
minDate = setYear(new Date(), CONST.CALENDAR_PICKER.MIN_YEAR),
maxDate = setYear(new Date(), CONST.CALENDAR_PICKER.MAX_YEAR),
onSelected,
onMonthOrYearSelected,
DayComponent = Day,
selectableDates,
headerContainerStyle,
containerStyle,
shouldEnableMonthYearBackdropInNarrowPane = false,
viewDate,
viewDateVersion = 0,
}: CalendarPickerProps) {
// eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
const {isSmallScreenWidth} = useResponsiveLayout();
Expand All @@ -94,10 +118,19 @@ function CalendarPicker({
const pressableRef = useRef<View>(null);
const monthPressableRef = useRef<View>(null);
const [currentDateView, setCurrentDateView] = useState(() => getInitialCurrentDateView(value, minDate, maxDate));
const [appliedViewDateVersion, setAppliedViewDateVersion] = useState(viewDateVersion);
const [isYearPickerVisible, setIsYearPickerVisible] = useState(false);
const [isMonthPickerVisible, setIsMonthPickerVisible] = useState(false);
const isFirstRender = useRef(true);

// Catching up with the caller here rather than in an effect keeps the view and the month matrix in step within a
// single render, so the calendar never paints the old month first. The date arrives already inside the allowed
// range, and is deliberately not clamped: clamping would show the limit's month instead of the typed one.
if (viewDate && viewDateVersion !== appliedViewDateVersion) {
setAppliedViewDateVersion(viewDateVersion);
setCurrentDateView(viewDate);
}

const currentMonthView = currentDateView.getMonth();
const currentYearView = currentDateView.getFullYear();
const calendarDaysMatrix = generateMonthMatrix(currentYearView, currentMonthView);
Expand All @@ -117,21 +150,22 @@ function CalendarPicker({
);

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;
});
const newCurrentDateView = setYearKeepingDay(new Date(currentDateView), year);
setCurrentDateView(newCurrentDateView);
setYears((prevYears) =>
prevYears.map((item) => ({
...item,
isSelected: item.value === newCurrentDateView.getFullYear(),
})),
);
onMonthOrYearSelected?.(format(newCurrentDateView, CONST.DATE.FNS_FORMAT_STRING));
requestAnimationFrame(() => setIsYearPickerVisible(false));
};

const onMonthSelected = (month: number) => {
setCurrentDateView((prev) => setMonth(new Date(prev), month));
const newCurrentDateView = setMonth(new Date(currentDateView), month);
setCurrentDateView(newCurrentDateView);
onMonthOrYearSelected?.(format(newCurrentDateView, CONST.DATE.FNS_FORMAT_STRING));
requestAnimationFrame(() => setIsMonthPickerVisible(false));
};

Expand Down
31 changes: 28 additions & 3 deletions src/components/DatePicker/DatePickerModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,23 @@ function DatePickerModal({
anchorPosition,
anchorAlignment = DEFAULT_ANCHOR_ORIGIN,
onSelected,
onMonthOrYearSelected,
shouldCloseWhenBrowserNavigationChanged = false,
shouldPositionFromTop = false,
forwardedFSClass,
shouldEnableMonthYearBackdropInNarrowPane = false,
anchorRef: anchorRefProp,
withoutOverlay = false,
shouldAllowWithoutOverlayInNarrowPane = false,
shouldCloseOnWheel = true,
viewDate,
viewDateVersion,
}: DatePickerProps) {
const [selectedDate, setSelectedDate] = useState(value ?? defaultValue ?? undefined);
const anchorRef = useRef<View>(null);
const fallbackAnchorRef = useRef<View>(null);
// PopoverProvider treats a click inside the anchor as "not outside", so the caller's own anchor has to be used
// or clicking the date input while the calendar is open would dismiss it.
const anchorRef = anchorRefProp ?? fallbackAnchorRef;
const styles = useThemeStyles();

// We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to distinguish RHL and narrow layout
Expand All @@ -64,13 +74,22 @@ function DatePickerModal({
}
}, [formID, inputID, selectedDate, shouldSaveDraft, value]);

const handleDateSelection = (newValue: string) => {
onSelected?.(newValue);
const applySelection = (newValue: string) => {
onTouched?.();
onInputChange?.(newValue);
setSelectedDate(newValue);
};

const handleDateSelection = (newValue: string) => {
onSelected?.(newValue);
applySelection(newValue);
};

const handleMonthOrYearSelection = (newValue: string) => {
onMonthOrYearSelected?.(newValue);
applySelection(newValue);
};

// Pass the CalendarPicker's existing bottom padding (pb4) as the base style so the safe-area padding is
// added on top of it instead of overriding it (containerStyle is applied after pb4 in CalendarPicker).
// The modal doesn't render an offline indicator inside it, so disable the offline-indicator padding —
Expand All @@ -95,14 +114,20 @@ function DatePickerModal({
forwardedFSClass={forwardedFSClass}
shouldDisplayBelowModals
enableEdgeToEdgeBottomSafeAreaPadding
withoutOverlay={withoutOverlay}
shouldAllowWithoutOverlayInNarrowPane={shouldAllowWithoutOverlayInNarrowPane}
shouldCloseOnWheel={shouldCloseOnWheel}
>
<CalendarPicker
minDate={minDate}
maxDate={maxDate}
value={selectedDate}
onSelected={handleDateSelection}
onMonthOrYearSelected={onMonthOrYearSelected ? handleMonthOrYearSelection : undefined}
containerStyle={bottomSafeAreaPaddingStyle}
shouldEnableMonthYearBackdropInNarrowPane={shouldEnableMonthYearBackdropInNarrowPane}
viewDate={viewDate}
viewDateVersion={viewDateVersion}
/>
</PopoverWithMeasuredContent>
);
Expand Down
Loading
Loading