diff --git a/src/CONST/index.ts b/src/CONST/index.ts
index a3783a956abe..fc1793fe5af5 100644
--- a/src/CONST/index.ts
+++ b/src/CONST/index.ts
@@ -1681,6 +1681,11 @@ const CONST = {
shortcutKey: 'Backspace',
modifiers: [],
},
+ DELETE: {
+ descriptionKey: null,
+ shortcutKey: 'Delete',
+ modifiers: [],
+ },
SPACE: {
descriptionKey: null,
shortcutKey: 'Space',
diff --git a/src/components/DatePicker/CalendarPicker/MonthPickerModal.tsx b/src/components/DatePicker/CalendarPicker/MonthPickerModal.tsx
index 904011d82d30..0e30a69f7c3b 100644
--- a/src/components/DatePicker/CalendarPicker/MonthPickerModal.tsx
+++ b/src/components/DatePicker/CalendarPicker/MonthPickerModal.tsx
@@ -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';
@@ -11,8 +12,10 @@ import DateUtils from '@libs/DateUtils';
import CONST from '@src/CONST';
+import type {View} from 'react-native';
+
import {Str} from 'expensify-common';
-import React, {useEffect, useState} from 'react';
+import React, {useEffect, useRef, useState} from 'react';
import {Keyboard} from 'react-native';
import type CalendarPickerListItem from './types';
@@ -41,6 +44,8 @@ type MonthPickerModalProps = {
function MonthPickerModal({isVisible, currentMonth, minMonth, maxMonth, onMonthChange, onClose, shouldEnableBackdropInNarrowPane = false}: MonthPickerModalProps) {
const styles = useThemeStyles();
const {translate, dateFnsLocale} = useLocalize();
+ const {setActivePopoverExtraAnchorRef} = usePopoverActions();
+ const contentRef = useRef(null);
const [searchText, setSearchText] = useState('');
// The rows are built here from numbers, so the compiler can cache them across the calendar's re-renders.
const months: CalendarPickerListItem[] = DateUtils.getMonthNames(dateFnsLocale).map((month, index) => ({
@@ -51,6 +56,16 @@ function MonthPickerModal({isVisible, currentMonth, minMonth, maxMonth, onMonthC
isDisabled: index < minMonth || index > maxMonth,
}));
+ 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 filteredMonths = searchText === '' ? months : months.filter((month) => month.text?.toLowerCase().includes(searchText.toLowerCase()));
const headerMessage = !filteredMonths.length ? translate('common.noResultsFound') : '';
const data = filteredMonths;
@@ -82,6 +97,7 @@ function MonthPickerModal({isVisible, currentMonth, minMonth, maxMonth, onMonthC
enableEdgeToEdgeBottomSafeAreaPadding
>
(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]);
+
// Freeze the year selected when the picker opened so it stays pinned to the top for the whole open cycle, even as the live selection changes.
const initialYear = useInitialSelection(currentYear, {isVisible});
// The rows are built here from numbers, so the compiler can cache them and the sort below across the calendar's re-renders.
@@ -97,6 +113,7 @@ function YearPickerModal({isVisible, minYear, maxYear, currentYear, onYearChange
enableEdgeToEdgeBottomSafeAreaPadding
>
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;
@@ -55,6 +61,21 @@ 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;
+
+ /**
+ * Called once the month or year picker has closed, whether a pick was made or not. Those pickers are modals, so
+ * they take focus with them, which a caller that owns the focused element has to take back.
+ */
+ onMonthOrYearPickerClose?: () => void;
};
/** A bound can be built from data, such as a time zone or a stored date, so an invalid one falls back to the default like a missing one, instead of making every date invalid. */
@@ -68,11 +89,15 @@ function CalendarPicker({
minDate: minDateProp,
maxDate: maxDateProp,
onSelected,
+ onMonthOrYearSelected,
DayComponent = Day,
isDateSelectable,
headerContainerStyle,
containerStyle,
shouldEnableMonthYearBackdropInNarrowPane = false,
+ viewDate,
+ viewDateVersion = 0,
+ onMonthOrYearPickerClose,
}: CalendarPickerProps) {
// eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
const {isSmallScreenWidth} = useResponsiveLayout();
@@ -83,7 +108,10 @@ function CalendarPicker({
const monthPressableRef = useRef>(null);
const minDate = getBound(minDateProp, CONST.CALENDAR_PICKER.MIN_YEAR);
const maxDate = getBound(maxDateProp, CONST.CALENDAR_PICKER.MAX_YEAR);
- const [dateView, setDateView] = useState(value ?? defaultMonth ?? new Date());
+ // The popover unmounts its content, so the calendar starts over every time it is reopened. A view the field has
+ // already asserted is what it starts from, since a date still being typed has no committed value to fall back to.
+ const [dateView, setDateView] = useState(() => viewDate ?? value ?? defaultMonth ?? new Date());
+ const [appliedViewDateVersion, setAppliedViewDateVersion] = useState(viewDateVersion);
// defaultMonth can be a new Date on every render, such as today, so the view follows it only when it moves to another month and nothing is selected.
const validDefaultMonth = defaultMonth && isValid(defaultMonth) ? defaultMonth : undefined;
const defaultMonthTime = validDefaultMonth && startOfMonth(validDefaultMonth).getTime();
@@ -94,6 +122,14 @@ function CalendarPicker({
setDateView(validDefaultMonth ?? new Date());
}
}
+
+ // 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.
+ if (viewDate && viewDateVersion !== appliedViewDateVersion) {
+ setAppliedViewDateVersion(viewDateVersion);
+ setDateView(viewDate);
+ }
+
const currentDateView = clamp(dateView, {start: minDate, end: maxDate});
const [isYearPickerVisible, setIsYearPickerVisible] = useState(false);
const [isMonthPickerVisible, setIsMonthPickerVisible] = useState(false);
@@ -113,13 +149,23 @@ function CalendarPicker({
const maxMonthStart = startOfMonth(maxDate);
const currentMonthStart = startOfMonth(currentDateView);
+ /**
+ * The year and month on offer are each held to the bounds, but the date they land on need not be, since the day and
+ * whichever of the two was not picked come along unchanged. The view is pulled into range on its way to the screen,
+ * so the date handed over is pulled in the same way rather than disagreeing with the month being shown.
+ */
+ const selectMonthOrYear = (newDateView: Date) => {
+ setDateView(newDateView);
+ onMonthOrYearSelected?.(format(clamp(newDateView, {start: minDate, end: maxDate}), CONST.DATE.FNS_FORMAT_STRING));
+ };
+
const onYearSelected = (year: number) => {
- setDateView(addYears(currentDateView, year - currentYearView));
+ selectMonthOrYear(addYears(currentDateView, year - currentYearView));
requestAnimationFrame(() => setIsYearPickerVisible(false));
};
const onMonthSelected = (month: number) => {
- setDateView(setMonth(currentDateView, month));
+ selectMonthOrYear(setMonth(currentDateView, month));
requestAnimationFrame(() => setIsMonthPickerVisible(false));
};
@@ -389,7 +435,10 @@ function CalendarPicker({
maxYear={maxYear}
currentYear={currentYearView}
onYearChange={onYearSelected}
- onClose={() => setIsYearPickerVisible(false)}
+ onClose={() => {
+ setIsYearPickerVisible(false);
+ onMonthOrYearPickerClose?.();
+ }}
shouldEnableBackdropInNarrowPane={shouldEnableMonthYearBackdropInNarrowPane}
/>
setIsMonthPickerVisible(false)}
+ onClose={() => {
+ setIsMonthPickerVisible(false);
+ onMonthOrYearPickerClose?.();
+ }}
shouldEnableBackdropInNarrowPane={shouldEnableMonthYearBackdropInNarrowPane}
/>
diff --git a/src/components/DatePicker/DatePickerModal.tsx b/src/components/DatePicker/DatePickerModal.tsx
index 762834252a8b..608c1eea86d9 100644
--- a/src/components/DatePicker/DatePickerModal.tsx
+++ b/src/components/DatePicker/DatePickerModal.tsx
@@ -52,16 +52,27 @@ 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,
+ onMonthOrYearPickerClose,
shouldMeasureContentHeight = false,
windowMargin,
shouldSwitchPositionIfOverflow = true,
}: DatePickerProps) {
const [selectedDate, setSelectedDate] = useState(value);
- const anchorRef = useRef>(null);
+ const fallbackAnchorRef = useRef>(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
@@ -77,14 +88,29 @@ function DatePickerModal({
}
}, [formID, inputID, selectedDate, shouldSaveDraft, value]);
- const handleDateSelection = (date: Date) => {
- const newValue = format(date, CONST.DATE.FNS_FORMAT_STRING);
- onSelected?.(newValue);
+ const applySelection = (newValue: string) => {
onTouched?.();
onInputChange?.(newValue);
setSelectedDate(newValue);
};
+ const handleDateSelection = (date: Date) => {
+ const newValue = format(date, CONST.DATE.FNS_FORMAT_STRING);
+ onSelected?.(newValue);
+ applySelection(newValue);
+ };
+
+ const applyMonthOrYearSelection = (newValue: string) => {
+ onMonthOrYearSelected?.(newValue);
+
+ // An empty field has no day to mark as selected, so the pick only moves the calendar
+ if (!value) {
+ return;
+ }
+
+ 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 —
@@ -110,6 +136,9 @@ function DatePickerModal({
forwardedFSClass={forwardedFSClass}
shouldDisplayBelowModals
enableEdgeToEdgeBottomSafeAreaPadding
+ withoutOverlay={withoutOverlay}
+ shouldAllowWithoutOverlayInNarrowPane={shouldAllowWithoutOverlayInNarrowPane}
+ shouldCloseOnWheel={shouldCloseOnWheel}
>
);
diff --git a/src/components/DatePicker/index.tsx b/src/components/DatePicker/index.tsx
index f86276f516dc..e558c0c18c35 100644
--- a/src/components/DatePicker/index.tsx
+++ b/src/components/DatePicker/index.tsx
@@ -1,26 +1,32 @@
+import FormContext from '@components/Form/FormContext';
import TextInput from '@components/TextInput';
import type {BaseTextInputProps, BaseTextInputRef} from '@components/TextInput/BaseTextInput/types';
import useAccessibilityAnnouncement from '@hooks/useAccessibilityAnnouncement';
import useAutoFocusInput from '@hooks/useAutoFocusInput';
+import useDateSegmentInput from '@hooks/useDateSegmentInput';
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
import useLocalize from '@hooks/useLocalize';
+import useRemeasureOnScroll from '@hooks/useRemeasureOnScroll';
+import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useStyleUtils from '@hooks/useStyleUtils';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
import ComposerFocusManager from '@libs/ComposerFocusManager';
-import {isNumeric} from '@libs/ValidationUtils';
+import {canUseTouchScreen} from '@libs/DeviceCapabilities';
+import {getDateRangeError, isNumeric} from '@libs/ValidationUtils';
import {setDraftValues} from '@userActions/FormActions';
import CONST from '@src/CONST';
import type {ComponentRef} from 'react';
-import type {TextInputKeyPressEvent} from 'react-native';
+import type {TextInput as TextInputRN, TextInputKeyPressEvent} from 'react-native';
+import {setYear} from 'date-fns';
import debounce from 'lodash/debounce';
-import React, {useCallback, useEffect, useRef, useState} from 'react';
+import React, {useCallback, useContext, useEffect, useRef, useState} from 'react';
import {Keyboard, View} from 'react-native';
import type {DateInputWithPickerProps} from './types';
@@ -54,6 +60,7 @@ function DatePicker({
rightHandSideComponent,
onPickerVisibilityChange,
shouldHideCalendarIcon = false,
+ onValidationErrorChange,
icon,
shouldEnableMonthYearBackdropInNarrowPane = false,
textInputContainerStyles,
@@ -65,9 +72,16 @@ function DatePicker({
// container instead of its own margins. Otherwise the X sits in a different spot than the icon it replaces.
const {paddingHorizontal: iconPaddingHorizontal} = StyleUtils.getTextInputIconContainerStyles(true);
const {windowHeight, windowWidth} = useWindowDimensions();
+ // The window's own width, and not the narrow layout, which a right hand pane reports even on a wide screen. Most
+ // date fields sit in one, and there is room to type beside them there.
+ // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
+ const {isSmallScreenWidth} = useResponsiveLayout();
const {translate} = useLocalize();
+ const {setInputValidationError} = useContext(FormContext);
const [isModalVisible, setIsModalVisible] = useState(false);
+ // The field is drawn as focused by its own input, which is only one of the segments, so the other two report it here
+ const [isFieldFocused, setIsFieldFocused] = useState(false);
const announcementMessage = label ? `${label}, ${translate('common.calendarOpened')}` : translate('common.calendarOpened');
useAccessibilityAnnouncement(announcementMessage, isModalVisible, {shouldAnnounceOnNative: true, shouldAnnounceOnWeb: true});
const [selectedDate, setSelectedDate] = useState(() => value ?? defaultValue ?? '');
@@ -78,11 +92,109 @@ function DatePicker({
// Whether the user currently intends the picker to be open. Lets a deferred measurement skip opening if the
// picker was dismissed before it resolved.
const openIntentRef = useRef(false);
+ // Focus moving out of the segments while the calendar is open is the user reaching for the calendar rather than
+ // leaving the field, so what a blur settles waits for the calendar to close.
+ const isFieldFocusedRef = useRef(false);
+ const hasPendingFieldBlurRef = useRef(false);
+
+ // Touch devices keep the calendar on its own, so the soft keyboard does not cover it. A narrow window has no room
+ // to show the calendar beside the field either, so it covers what is being typed.
+ const shouldAllowTyping = !canUseTouchScreen() && !isSmallScreenWidth;
+ const dateMask = translate('common.dateFormat');
+
+ // The calendar falls back to these bounds when it is given none, so validation follows the range it offers
+ const rangeMinDate = minDate ?? setYear(new Date(), CONST.CALENDAR_PICKER.MIN_YEAR);
+ const rangeMaxDate = maxDate ?? setYear(new Date(), CONST.CALENDAR_PICKER.MAX_YEAR);
+
+ // A date the calendar could never have offered is one nothing downstream expects, and typing is what makes those
+ // reachable. Returns an empty string when the date is fine, which is how the form reads "no error".
+ const getRangeError = (date: string) => (date ? getDateRangeError(translate, date, rangeMinDate, rangeMaxDate) : '');
+
+ // Updates the field without ending the selection, so the calendar stays open for whatever the user does next
+ const commitDate = (newDate: string) => {
+ setSelectedDate(newDate);
+ const rangeError = getRangeError(newDate);
+
+ // The form validates from inside onInputChange, which runs before this render's effects, so the error has to
+ // be recorded here as well for it to clear in the same keystroke that fixes the date.
+ setInputValidationError(inputID, rangeError);
+
+ // A date being typed reads as empty until it is finished. Marking the field touched then would show a
+ // required error over a date the user is part way through.
+ if (newDate) {
+ onTouched?.();
+ }
+
+ // Only typing can reach a date the calendar would never have offered, and a consumer that saves on every
+ // change would have stored it before anything could object. It stays on screen under its error to be corrected.
+ if (rangeError) {
+ return;
+ }
+
+ onInputChange?.(newDate);
+ };
+
+ const setPickerVisibility = useCallback(
+ (isVisible: boolean) => {
+ setIsModalVisible(isVisible);
+ onPickerVisibilityChange?.(isVisible);
+ },
+ [onPickerVisibilityChange],
+ );
+
+ const closeDatePicker = useCallback(() => {
+ openIntentRef.current = false;
+ setPickerVisibility(false);
+
+ if (!shouldDismissKeyboardBeforeShow || shouldAllowTyping) {
+ return;
+ }
+
+ textInputRef.current?.blur();
+ ComposerFocusManager.blurActiveInput();
+ Keyboard.dismiss();
+ }, [shouldDismissKeyboardBeforeShow, shouldAllowTyping, setPickerVisibility]);
+
+ // The hook is the single gate on typing. When the platform does not allow it, the handlers it returns are no-ops
+ // and the value passes straight through, so the call sites below do not have to check again.
+ const segmentInput = useDateSegmentInput({
+ value: selectedDate,
+ isEnabled: shouldAllowTyping,
+ minDate: rangeMinDate,
+ maxDate: rangeMaxDate,
+ onCommit: commitDate,
+ // Tabbing away leaves a calendar nothing has been pressed outside of, which is what otherwise dismisses it
+ onLeaveByKeyboard: () => {
+ if (!isModalVisible) {
+ return;
+ }
+
+ closeDatePicker();
+ },
+ });
const {inputCallbackRef: autoFocusCallbackRef, cancelAutoFocus} = useAutoFocusInput();
const autoFocusCallbackRefRef = useRef(autoFocusCallbackRef);
autoFocusCallbackRefRef.current = autoFocusCallbackRef;
+ // Whether focus is being placed by autofocus rather than by the user
+ const isAutoFocusingRef = useRef(false);
+ const segmentInputRef = useRef(segmentInput);
+ useEffect(() => {
+ segmentInputRef.current = segmentInput;
+ });
+
+ // The field's own ref leads to the year, since that is where typing starts in an empty field. A text field answers
+ // autofocus with the caret where typing would carry on instead, which is what this stands in to do.
+ const autoFocusTargetRef = useRef({
+ focus: () => {
+ isAutoFocusingRef.current = true;
+ segmentInputRef.current.focusFirstUnfilledSegment();
+ isAutoFocusingRef.current = false;
+ },
+ blur: () => textInputRef.current?.blur(),
+ });
+
useEffect(() => {
if (shouldSaveDraft && formID) {
setDraftValues(formID, {[inputID]: selectedDate});
@@ -93,9 +205,14 @@ function DatePicker({
if (value === undefined) {
return;
}
+ // A date part way through being typed reports itself as empty, so the value prop trails a render behind the
+ // field. Putting it back while the user is still entering a date would undo the digit they just entered.
+ if (segmentInput.isEditing) {
+ return;
+ }
setSelectedDate(value);
- }, [formID, inputID, selectedDate, shouldSaveDraft, value]);
+ }, [formID, inputID, selectedDate, shouldSaveDraft, value, segmentInput.isEditing]);
const calculatePopoverPosition = useCallback(
(onMeasured?: () => void) => {
@@ -114,20 +231,27 @@ function DatePicker({
[windowHeight],
);
- const setPickerVisibility = useCallback(
- (isVisible: boolean) => {
- setIsModalVisible(isVisible);
- onPickerVisibilityChange?.(isVisible);
- },
- [onPickerVisibilityChange],
- );
-
const showDatePickerModal = useCallback(() => {
+ // Re-opening would remeasure and re-announce a calendar that is already showing. Both a press and a focus can
+ // ask for it, and while typing both arrive for a single click.
+ if (isModalVisible) {
+ return;
+ }
+
+ // Autofocus is the screen arriving rather than the user asking for a calendar
+ if (isAutoFocusingRef.current) {
+ return;
+ }
+
cancelAutoFocus();
- // Blur the date input before showing the modal, so the focus won't be returned after the modal is closed
- textInputRef.current?.blur();
- if (shouldDismissKeyboardBeforeShow) {
+ // While typing is allowed the calendar sits under an input the user is still writing in, so the caret has to
+ // stay put. Otherwise blur first so focus is not returned once the modal closes.
+ if (!shouldAllowTyping) {
+ textInputRef.current?.blur();
+ }
+
+ if (shouldDismissKeyboardBeforeShow && !shouldAllowTyping) {
// Blur whichever input is focused (e.g. a preceding text field) so closing the picker does not briefly restore its keyboard.
ComposerFocusManager.blurActiveInput();
// Dismiss in parallel with opening — do not await the hide animation or the open feels sluggish.
@@ -151,26 +275,16 @@ function DatePicker({
};
openPicker();
- }, [shouldDeferShowUntilPositioned, shouldDismissKeyboardBeforeShow, calculatePopoverPosition, cancelAutoFocus, setPickerVisibility]);
-
- const closeDatePicker = useCallback(() => {
- openIntentRef.current = false;
- setPickerVisibility(false);
-
- if (!shouldDismissKeyboardBeforeShow) {
- return;
- }
-
- textInputRef.current?.blur();
- ComposerFocusManager.blurActiveInput();
- Keyboard.dismiss();
- }, [shouldDismissKeyboardBeforeShow, setPickerVisibility]);
+ }, [isModalVisible, shouldDeferShowUntilPositioned, shouldDismissKeyboardBeforeShow, shouldAllowTyping, calculatePopoverPosition, cancelAutoFocus, setPickerVisibility]);
const handlePress = useCallback>(
(event) => {
+ // The field focuses its own input on any press it is not told to leave alone, which would be the year
+ // whichever segment was actually pressed. The segments are focused by the press itself instead.
if ('preventDefault' in event) {
event.preventDefault();
}
+
showDatePickerModal();
},
[showDatePickerModal],
@@ -190,15 +304,120 @@ function DatePicker({
const handleDateSelected = (newDate: string) => {
onTouched?.();
setSelectedDate(newDate);
+ segmentInput.onDateSelected(newDate);
closeDatePicker();
// Defer until after the popover close is committed so consumers are not suppressed by modal state.
requestAnimationFrame(() => onInputChange?.(newDate));
};
+ // Only the typing calendar stays open while the page scrolls. Every other one is dismissed instead, so it never
+ // has to follow the field, and following it keeps an edit in progress from being interrupted.
+ useRemeasureOnScroll({isActive: shouldAllowTyping && isModalVisible, remeasure: calculatePopoverPosition});
+
+ // The error text renders inside the anchor, so showing or hiding it changes the height the calendar was positioned
+ // from. Remeasuring on the anchor's own layout covers that without having to name each thing that can resize it.
+ const repositionOnAnchorLayout = () => {
+ if (!isModalVisible) {
+ return;
+ }
+
+ calculatePopoverPosition();
+ };
+
+ // Digits that do not add up to a date read as no date at all, so a form with no rule about this field would accept
+ // the entry in silence.
+ const ownError = segmentInput.hasInvalidEntry ? translate('common.error.dateInvalid') : getRangeError(selectedDate);
+ // Nullish coalescing would keep an empty errorText, which is a form reporting no error rather than an empty one
+ const dateErrorText = errorText || ownError;
+
+ // An entry part way through is never handed over, so the field still holds the date from before it. Saving that
+ // would discard an edit the user is in the middle of, and the error is not shown because they are still making it.
+ const blockingError = segmentInput.hasIncompleteEntry ? translate('common.error.dateInvalid') : ownError;
+
+ useEffect(() => {
+ setInputValidationError(inputID, blockingError);
+ onValidationErrorChange?.(blockingError);
+
+ // A field that is gone has nothing left to report, so its error must not outlive it and block the form
+ return () => {
+ setInputValidationError(inputID, '');
+ onValidationErrorChange?.('');
+ };
+ }, [inputID, blockingError, setInputValidationError, onValidationErrorChange]);
+
+ const leaveField = () => {
+ isFieldFocusedRef.current = false;
+ setIsFieldFocused(false);
+
+ if (isModalVisible) {
+ hasPendingFieldBlurRef.current = true;
+ return;
+ }
+
+ segmentInput.onFieldBlur();
+ };
+
+ useEffect(() => {
+ if (isModalVisible || !hasPendingFieldBlurRef.current || isFieldFocusedRef.current) {
+ return;
+ }
+
+ hasPendingFieldBlurRef.current = false;
+ segmentInput.onFieldBlur();
+ // The handler is rebuilt on every render, and the refs above are what decide whether it has anything to do
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [isModalVisible]);
+
+ const commitPickedMonthOrYear = (newDate: string) => {
+ // Picking a month or year revises a date the field already holds. An empty field has no day to go with one,
+ // so it waits for the day press rather than being filled with a day nobody chose.
+ if (!selectedDate) {
+ return;
+ }
+
+ commitDate(newDate);
+
+ // A month or year pick can land on the date the field already holds, and a segment the user has taken a digit
+ // out of reports no date at all, so the committed value never changes and nothing else tells the segments.
+ segmentInput.onDateSelected(newDate);
+ };
+
+ // The pickers are modals of their own, so they take focus with them, and their focus trap holds on to it until
+ // they are gone. The calendar is still open at that point, which is the field still being used.
+ const returnFocusFromMonthOrYearPicker = () => {
+ if (!isModalVisible) {
+ return;
+ }
+
+ segmentInput.restoreFocus();
+ };
+
+ const enterField = () => {
+ isFieldFocusedRef.current = true;
+ setIsFieldFocused(true);
+ hasPendingFieldBlurRef.current = false;
+ showDatePickerModal();
+ };
+
+ // Autofocus leaves the field focused without a calendar, so editing the date is what asks for one, as typing does
+ // on a field that cannot be typed into
+ const showCalendarOnEdit = (event: TextInputKeyPressEvent) => {
+ const key = event.nativeEvent.key;
+
+ if (!isNumeric(key) && key !== CONST.KEYBOARD_SHORTCUTS.BACKSPACE.shortcutKey && key !== CONST.KEYBOARD_SHORTCUTS.DELETE.shortcutKey) {
+ return;
+ }
+
+ showDatePickerModal();
+ };
+
const handleClear = () => {
onTouched?.();
+ setInputValidationError(inputID, '');
+ onValidationErrorChange?.('');
onInputChange?.('');
setSelectedDate('');
+ segmentInput.onClear();
};
useEffect(() => {
@@ -216,19 +435,23 @@ function DatePicker({
const combinedTextInputRef = useCallback(
(ref: BaseTextInputRef | null) => {
textInputRef.current = ref;
- if (autoFocus) {
- (autoFocusCallbackRefRef.current as unknown as (ref: BaseTextInputRef | null) => void)(ref);
+ if (!autoFocus) {
+ return;
}
+
+ const autoFocusTarget = ref && shouldAllowTyping ? autoFocusTargetRef.current : ref;
+ autoFocusCallbackRefRef.current(autoFocusTarget as ComponentRef | null);
},
// autoFocusCallbackRefRef is a stable ref — its identity never changes, so it's not a dep
- [autoFocus],
+ [autoFocus, shouldAllowTyping],
);
return (
<>
showDatePickerModal()}
- onSubmitEditing={() => showDatePickerModal()}
- onKeyPress={handleInputKeyPress}
- textInputContainerStyles={[textInputContainerStyles, isModalVisible && styles.borderColorFocus]}
+ hideFocusedState={shouldDismissKeyboardBeforeShow && !shouldAllowTyping}
+ onPress={shouldDismissKeyboardBeforeShow || shouldAllowTyping ? handlePress : () => showDatePickerModal()}
+ onSubmitEditing={shouldAllowTyping ? undefined : () => showDatePickerModal()}
+ // Reaching the field by keyboard never fires a press, so focus is what opens the calendar
+ onFocus={shouldAllowTyping ? enterField : undefined}
+ onKeyPress={shouldAllowTyping ? showCalendarOnEdit : handleInputKeyPress}
+ textInputContainerStyles={[textInputContainerStyles, (isModalVisible || isFieldFocused) && styles.borderColorFocus]}
shouldHideClearButton={shouldHideClearButton}
onClearInput={handleClear}
clearButtonStyle={[styles.mh0, {paddingHorizontal: iconPaddingHorizontal}]}
forwardedFSClass={forwardedFSClass}
autoComplete={autoComplete}
- disableKeyboard
+ disableKeyboard={!shouldAllowTyping}
rightHandSideComponent={rightHandSideComponent}
/>
@@ -277,6 +520,14 @@ function DatePicker({
shouldPositionFromTop={!isInverted}
forwardedFSClass={forwardedFSClass}
shouldCloseWhenBrowserNavigationChanged
+ anchorRef={anchorRef}
+ withoutOverlay={shouldAllowTyping}
+ shouldAllowWithoutOverlayInNarrowPane={shouldAllowTyping}
+ shouldCloseOnWheel={!shouldAllowTyping}
+ viewDate={segmentInput.viewDate}
+ viewDateVersion={segmentInput.viewDateVersion}
+ onMonthOrYearSelected={shouldAllowTyping ? commitPickedMonthOrYear : undefined}
+ onMonthOrYearPickerClose={shouldAllowTyping ? returnFocusFromMonthOrYearPicker : undefined}
shouldEnableMonthYearBackdropInNarrowPane={shouldEnableMonthYearBackdropInNarrowPane}
/>
>
diff --git a/src/components/DatePicker/types.ts b/src/components/DatePicker/types.ts
index d8170c4a43af..86985c0b9fcf 100644
--- a/src/components/DatePicker/types.ts
+++ b/src/components/DatePicker/types.ts
@@ -5,7 +5,8 @@ import type {ForwardedFSClassProps} from '@libs/Fullstory/types';
import type {OnyxFormValuesMapping} from '@src/ONYXKEYS';
-import type {StyleProp, ViewStyle} from 'react-native';
+import type {ComponentRef, RefObject} from 'react';
+import type {StyleProp, View, ViewStyle} from 'react-native';
type DatePickerBaseProps = ForwardedFSClassProps & {
/** The selected date as a `yyyy-MM-dd` string, read with DateUtils.parseCalendarDate. `onInputChange` is called with the picked date in the same format. */
@@ -85,6 +86,13 @@ type DateInputWithPickerProps = DatePickerBaseProps &
* @default false
*/
shouldHideCalendarIcon?: boolean;
+
+ /**
+ * Reports an error the field works out for itself, such as a date outside the range its calendar offers or
+ * digits that do not make a date. A field inside a form reports these to the form, so this is for the pages
+ * that own their own submit. An empty string withdraws the error.
+ */
+ onValidationErrorChange?: (error: string) => void;
};
type DatePickerProps = {
@@ -136,6 +144,27 @@ type DatePickerProps = {
*/
shouldEnableMonthYearBackdropInNarrowPane?: boolean;
+ /**
+ * The element the calendar is positioned against, normally the date input. Clicks inside it do not dismiss the
+ * calendar, so the user can keep editing the input while the calendar is open. Falls back to an internal ref.
+ */
+ anchorRef?: RefObject | null>;
+
+ /** Moves the calendar to this month without selecting a day, so it follows the date being typed into the input */
+ viewDate?: Date;
+
+ /** Changes every time `viewDate` is asserted, including when it repeats the month the calendar already shows */
+ viewDateVersion?: number;
+
+ /**
+ * Called when a month or year is picked from its own picker, with the date that pick lands on. The calendar stays
+ * open, so the user can still press a day. Leave it out to have those picks only move the view.
+ */
+ onMonthOrYearSelected?: (selectedDate: string) => void;
+
+ /** Called once the month or year picker has closed, which is where focus has to be taken back from it */
+ onMonthOrYearPickerClose?: () => void;
+
/**
* Whether to measure the calendar's rendered height instead of assuming `POPOVER_DATE_MIN_HEIGHT`, which only covers
* a 5-row month: months that need a sixth week row reach `POPOVER_DATE_MAX_HEIGHT` instead. Needed where the popover
diff --git a/src/components/DateSegmentsInput.tsx b/src/components/DateSegmentsInput.tsx
new file mode 100644
index 000000000000..ed397e7b1a4b
--- /dev/null
+++ b/src/components/DateSegmentsInput.tsx
@@ -0,0 +1,218 @@
+/**
+ * The inner part of the date field, rendering one input per date segment. It is registered as a `BaseTextInput` input
+ * type, so it sits inside the same label, border and error chrome as any other text field.
+ */
+import useLocalize from '@hooks/useLocalize';
+import useStyleUtils from '@hooks/useStyleUtils';
+import useTheme from '@hooks/useTheme';
+import useThemeStyles from '@hooks/useThemeStyles';
+
+import {getDateMaskParts} from '@libs/DateInputMaskUtils';
+import type {DateSegmentName} from '@libs/DateInputMaskUtils';
+import mergeRefs from '@libs/mergeRefs';
+
+import CONST from '@src/CONST';
+
+import type {ViewStyle} from 'react-native';
+
+import React, {useState} from 'react';
+import {StyleSheet, View} from 'react-native';
+
+import type {AnimatedTextInputRef} from './RNTextInput';
+import type {BaseTextInputProps} from './TextInput/BaseTextInput/types';
+
+import {PressableWithoutFeedback} from './Pressable';
+import RNTextInput from './RNTextInput';
+import Text from './Text';
+
+/** A measured width is rounded to a whole pixel, which can leave it short of the text and wrap the mask */
+const WIDTH_ROUNDING_ALLOWANCE = 1;
+
+/** Reads back the padding the field hands its text, which is what a selection has to leave alone to cover the line */
+function getVerticalPadding(inputStyle: BaseTextInputProps['style']): ViewStyle {
+ const {paddingTop, paddingBottom, paddingVertical} = StyleSheet.flatten(inputStyle) ?? {};
+
+ return {paddingTop, paddingBottom, paddingVertical};
+}
+
+function DateSegmentsInput({
+ dateSegmentsConfig,
+ style,
+ placeholderTextColor,
+ disabled,
+ onPressOut,
+ onFocus,
+ onBlur,
+ onKeyPress,
+ readOnly,
+ forwardedFSClass,
+ accessibilityLabel,
+ 'aria-describedby': ariaDescribedBy,
+ 'aria-invalid': ariaInvalid,
+ ref,
+}: BaseTextInputProps) {
+ const theme = useTheme();
+ const styles = useThemeStyles();
+ const StyleUtils = useStyleUtils();
+ const {translate} = useLocalize();
+ // Mask letters are wider than the digits that replace them, so each segment is measured from the text it shows
+ const [measuredWidths, setMeasuredWidths] = useState>>({});
+
+ if (!dateSegmentsConfig) {
+ return null;
+ }
+
+ const {mask, getSegmentProps, setSegmentRef, focusFirstUnfilledSegment, selectLastSegment, selectAllSegments, isSegmentElement, isAllSelected, onFieldBlur} = dateSegmentsConfig;
+
+ const parts = getDateMaskParts(mask).map((part) => ({...part, segmentProps: getSegmentProps(part.name)}));
+
+ // A form hangs its validation on the field's blur, so it only runs once focus leaves the whole field
+ const reportFieldBlur: NonNullable = (event) => {
+ if ('relatedTarget' in event.nativeEvent && isSegmentElement(event.nativeEvent.relatedTarget)) {
+ return;
+ }
+
+ onFieldBlur();
+ onBlur?.(event);
+ };
+
+ // The separators belong to the mask, so they read as placeholder text while any of it is on screen
+ const isAnyMaskShowing = parts.some((part) => part.segmentProps.value.length < part.placeholder.length);
+
+ // The field's input style stretches across the row, which would spread the segments apart
+ const sizedToContent = [styles.flexGrow0, styles.flexShrink0, styles.flexBasisAuto, styles.wAuto];
+
+ // The mask letters and the separators between them read as placeholder text rather than as the entered date. The
+ // field always hands down a theme color, though the prop's type also allows a platform color that has no string.
+ const maskColorStyle = StyleUtils.getColorStyle(typeof placeholderTextColor === 'string' ? placeholderTextColor : theme.placeholderText);
+
+ return (
+
+ {/* Sized to the date rather than to the row, so selecting it covers the digits and not the space after them */}
+
+ {/* Taking the field's vertical padding leaves exactly the line of text to fill */}
+ {isAllSelected && (
+
+
+
+ )}
+ {parts.map((part) => {
+ const segmentProps = part.segmentProps;
+ // The mask letters a half typed segment has not reached yet stay on screen, so the date keeps its shape
+ const remainder = part.placeholder.slice(segmentProps.value.length);
+ const measuredText = `${segmentProps.value}${remainder}`;
+ const width = (measuredWidths[part.name] ?? measuredText.length * CONST.CHARACTER_WIDTH) + WIDTH_ROUNDING_ALLOWANCE;
+
+ return (
+
+ {
+ const layoutWidth = event.nativeEvent.layout.width;
+ if (!layoutWidth) {
+ return;
+ }
+
+ setMeasuredWidths((previous) => (previous[part.name] === layoutWidth ? previous : {...previous, [part.name]: layoutWidth}));
+ }}
+ >
+ {measuredText}
+
+ {/* A row, since the input's `flex: 1` would take a column's height away */}
+
+ setSegmentRef(part.name, element))}
+ style={[style, styles.ph0, styles.w100]}
+ value={segmentProps.value}
+ onKeyPress={(event) => {
+ onKeyPress?.(event);
+ segmentProps.onKeyPress(event);
+ }}
+ onChangeText={segmentProps.onChangeText}
+ onFocus={(event) => {
+ segmentProps.onFocus();
+ onFocus?.(event);
+ }}
+ onBlur={reportFieldBlur}
+ onPressOut={onPressOut}
+ // react-native-web gives a text input no press events, so the press that collapses
+ // a whole field selection has to be caught as a pointer one
+ onPointerDown={segmentProps.onPointerDown}
+ accessibilityLabel={translate(`common.dateSegments.${part.name}`)}
+ // The next digit replaces the whole selected date, so a caret would mislead
+ caretHidden={isAllSelected}
+ inputMode="numeric"
+ disabled={disabled}
+ readOnly={readOnly}
+ forwardedFSClass={forwardedFSClass}
+ aria-describedby={ariaDescribedBy}
+ aria-invalid={ariaInvalid}
+ />
+ {/* The typed digits repeat invisibly so the mask letters start where the input's text ends */}
+ {!!remainder && (
+
+ {segmentProps.value}
+ {remainder}
+
+ )}
+
+ {!!part.separator && (
+
+ {part.separator}
+
+ )}
+
+ );
+ })}
+
+ {
+ event.preventDefault();
+
+ // A text field answers a double click with the word under it and a triple click with the whole
+ // line. The space beside the date holds no text of its own, so the nearest segment stands in for
+ // the word, the way clicking past the end of a line does.
+ if (event.detail === 2) {
+ selectLastSegment();
+ return;
+ }
+
+ if (event.detail > 2) {
+ selectAllSegments();
+ }
+ }}
+ onPress={focusFirstUnfilledSegment}
+ // The field hands its own press down as `onPressOut`, which is what opens the calendar. Focus alone
+ // cannot stand in for it here, since a segment that already holds focus announces nothing on arrival.
+ onPressOut={onPressOut}
+ sentryLabel="DateSegmentsInput-EmptySpace"
+ style={[styles.flex1, styles.cursorText]}
+ />
+
+ );
+}
+
+export default DateSegmentsInput;
diff --git a/src/components/Form/FormContext.tsx b/src/components/Form/FormContext.tsx
index 9a94fd3f5a5f..19593da5072b 100644
--- a/src/components/Form/FormContext.tsx
+++ b/src/components/Form/FormContext.tsx
@@ -11,6 +11,13 @@ type FormContext = {
registerInput: RegisterInput;
getErrorAnnouncementKey: () => number;
getFallbackAnnouncementMessage: () => string;
+
+ /**
+ * Reports an error the input's own value is enough to decide, such as a date outside the range its calendar
+ * offers. A value the input refuses to commit never reaches the form, so without this the page would have no
+ * rule to write and would submit the entry in silence. An empty string withdraws the error.
+ */
+ setInputValidationError: (inputID: keyof Form, error: string) => void;
};
export default createContext({
@@ -19,6 +26,9 @@ export default createContext({
},
getErrorAnnouncementKey: () => 0,
getFallbackAnnouncementMessage: () => '',
+
+ // Inputs are usable on their own outside a form, where the page owns its own submit and there is nothing to tell
+ setInputValidationError: () => {},
});
export type {RegisterInput};
diff --git a/src/components/Form/FormProvider.tsx b/src/components/Form/FormProvider.tsx
index 0d9c163f738e..ca5b68c33ad8 100644
--- a/src/components/Form/FormProvider.tsx
+++ b/src/components/Form/FormProvider.tsx
@@ -164,6 +164,9 @@ function FormProvider({
const inputRefs = useRef({});
const formWrapperRef = useRef(null);
const touchedInputs = useRef>({});
+
+ // A ref rather than state, so an input reporting its own error cannot drive a render of the form it sits in
+ const inputValidationErrors = useRef>({});
const [inputValues, setInputValues] = useState