From c306f00ad8e4958433239c7dac80a01480212031 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 17 Sep 2026 20:17:07 +0530 Subject: [PATCH 01/23] feat: allow typing a date into the date field, one segment at a time Signed-off-by: krishna2323 --- src/components/DatePicker/DatePickerModal.tsx | 10 +- src/components/DatePicker/index.tsx | 64 +++- src/components/DatePicker/types.ts | 9 + src/components/Modal/BaseModal.tsx | 2 + .../Modal/ReanimatedModal/index.tsx | 3 +- src/components/Modal/ReanimatedModal/types.ts | 6 + src/hooks/useDateSegmentInput.ts | 195 ++++++++++++ src/libs/DateInputMaskUtils.ts | 279 ++++++++++++++++++ .../isTypedDateInputSupported/index.native.ts | 5 + src/libs/isTypedDateInputSupported/index.ts | 11 + src/libs/isTypedDateInputSupported/types.ts | 3 + tests/unit/DateInputMaskUtilsTest.ts | 206 +++++++++++++ 12 files changed, 775 insertions(+), 18 deletions(-) create mode 100644 src/hooks/useDateSegmentInput.ts create mode 100644 src/libs/DateInputMaskUtils.ts create mode 100644 src/libs/isTypedDateInputSupported/index.native.ts create mode 100644 src/libs/isTypedDateInputSupported/index.ts create mode 100644 src/libs/isTypedDateInputSupported/types.ts create mode 100644 tests/unit/DateInputMaskUtilsTest.ts diff --git a/src/components/DatePicker/DatePickerModal.tsx b/src/components/DatePicker/DatePickerModal.tsx index 5ea4f13a430e..d6f0d12a52d1 100644 --- a/src/components/DatePicker/DatePickerModal.tsx +++ b/src/components/DatePicker/DatePickerModal.tsx @@ -46,9 +46,15 @@ function DatePickerModal({ shouldPositionFromTop = false, forwardedFSClass, shouldEnableMonthYearBackdropInNarrowPane = false, + anchorRef: anchorRefProp, + hasBackdrop, + shouldDisableFocusTrap = false, }: DatePickerProps) { const [selectedDate, setSelectedDate] = useState(value ?? defaultValue ?? undefined); - 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 @@ -95,6 +101,8 @@ function DatePickerModal({ forwardedFSClass={forwardedFSClass} shouldDisplayBelowModals enableEdgeToEdgeBottomSafeAreaPadding + hasBackdrop={hasBackdrop} + shouldDisableFocusTrap={shouldDisableFocusTrap} > { + setSelectedDate(newDate); + onTouched?.(); + onInputChange?.(newDate); + }; + + const segmentInput = useDateSegmentInput({value: selectedDate, mask: dateMask, isEnabled: shouldAllowTyping, onCommit: handleTypedDate}); + const {inputCallbackRef: autoFocusCallbackRef, cancelAutoFocus} = useAutoFocusInput(); const autoFocusCallbackRefRef = useRef(autoFocusCallbackRef); autoFocusCallbackRefRef.current = autoFocusCallbackRef; @@ -114,10 +127,14 @@ function DatePicker({ const showDatePickerModal = useCallback(() => { 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. @@ -141,29 +158,36 @@ function DatePicker({ }; openPicker(); - }, [shouldDeferShowUntilPositioned, shouldDismissKeyboardBeforeShow, calculatePopoverPosition, cancelAutoFocus, setPickerVisibility]); + }, [shouldDeferShowUntilPositioned, shouldDismissKeyboardBeforeShow, shouldAllowTyping, calculatePopoverPosition, cancelAutoFocus, setPickerVisibility]); const closeDatePicker = useCallback(() => { openIntentRef.current = false; setPickerVisibility(false); - if (!shouldDismissKeyboardBeforeShow) { + if (!shouldDismissKeyboardBeforeShow || shouldAllowTyping) { return; } textInputRef.current?.blur(); ComposerFocusManager.blurActiveInput(); Keyboard.dismiss(); - }, [shouldDismissKeyboardBeforeShow, setPickerVisibility]); + }, [shouldDismissKeyboardBeforeShow, shouldAllowTyping, setPickerVisibility]); const handlePress = useCallback>( (event) => { - if ('preventDefault' in event) { + // Preventing the press would also stop the caret from landing in the segment the user clicked. + if (!shouldAllowTyping && 'preventDefault' in event) { event.preventDefault(); } + + // Clicking from one segment to another must not remeasure and reopen a calendar that is already showing. + if (shouldAllowTyping && isModalVisible) { + return; + } + showDatePickerModal(); }, - [showDatePickerModal], + [shouldAllowTyping, isModalVisible, showDatePickerModal], ); const handleInputKeyPress = useCallback( @@ -239,21 +263,26 @@ function DatePicker({ accessibilityLabel={label} role={CONST.ROLE.COMBOBOX} accessibilityState={{expanded: isModalVisible}} - value={selectedDate} - placeholder={placeholder ?? translate('common.dateFormat')} + value={segmentInput.displayValue} + selection={segmentInput.selection} + placeholder={placeholder ?? dateMask} errorText={errorText} - inputStyle={styles.pointerEventsNone} + inputStyle={shouldAllowTyping ? undefined : styles.pointerEventsNone} disabled={disabled} - hideFocusedState={shouldDismissKeyboardBeforeShow} - onPress={shouldDismissKeyboardBeforeShow ? handlePress : () => showDatePickerModal()} - onSubmitEditing={() => showDatePickerModal()} - onKeyPress={handleInputKeyPress} + hideFocusedState={shouldDismissKeyboardBeforeShow && !shouldAllowTyping} + onPress={shouldDismissKeyboardBeforeShow || shouldAllowTyping ? handlePress : () => showDatePickerModal()} + onSubmitEditing={shouldAllowTyping ? undefined : () => showDatePickerModal()} + onFocus={shouldAllowTyping ? segmentInput.onFocus : undefined} + onBlur={shouldAllowTyping ? segmentInput.onBlur : undefined} + onChangeText={shouldAllowTyping ? segmentInput.onChangeText : undefined} + onSelectionChange={shouldAllowTyping ? segmentInput.onSelectionChange : undefined} + onKeyPress={shouldAllowTyping ? segmentInput.onKeyPress : handleInputKeyPress} textInputContainerStyles={isModalVisible ? styles.borderColorFocus : {}} shouldHideClearButton={shouldHideClearButton} onClearInput={handleClear} forwardedFSClass={forwardedFSClass} autoComplete={autoComplete} - disableKeyboard + disableKeyboard={!shouldAllowTyping} rightHandSideComponent={rightHandSideComponent} /> @@ -270,6 +299,9 @@ function DatePicker({ shouldPositionFromTop={!isInverted} forwardedFSClass={forwardedFSClass} shouldCloseWhenBrowserNavigationChanged + anchorRef={anchorRef} + hasBackdrop={shouldAllowTyping ? false : undefined} + shouldDisableFocusTrap={shouldAllowTyping} /> ); diff --git a/src/components/DatePicker/types.ts b/src/components/DatePicker/types.ts index 8f54da70932f..32c214cf8de8 100644 --- a/src/components/DatePicker/types.ts +++ b/src/components/DatePicker/types.ts @@ -5,6 +5,9 @@ import type {ForwardedFSClassProps} from '@libs/Fullstory/types'; import type {OnyxFormValuesMapping} from '@src/ONYXKEYS'; +import type {RefObject} from 'react'; +import type {View} from 'react-native'; + type DatePickerBaseProps = ForwardedFSClassProps & { /** * The datepicker supports any value that `new Date()` can parse. @@ -135,6 +138,12 @@ type DatePickerProps = { * Used by inline editing flows that require background dimming. */ 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; } & Omit; export type {DateInputWithPickerProps, DatePickerProps}; diff --git a/src/components/Modal/BaseModal.tsx b/src/components/Modal/BaseModal.tsx index 12c5ec70c794..5cb6c2f92f7e 100644 --- a/src/components/Modal/BaseModal.tsx +++ b/src/components/Modal/BaseModal.tsx @@ -77,6 +77,7 @@ function BaseModal({ enableEdgeToEdgeBottomSafeAreaPadding, shouldApplySidePanelOffset: shouldApplySidePanelOffsetProp, hasBackdrop, + shouldDisableFocusTrap = false, backdropOpacity, shouldDisableBottomSafeAreaPadding = false, shouldIgnoreBackHandlerDuringTransition = false, @@ -369,6 +370,7 @@ function BaseModal({ backdropOpacity={backdropOpacityAdjusted} backdropTransitionOutTiming={0} hasBackdrop={hasBackdrop ?? fullscreen} + shouldDisableFocusTrap={shouldDisableFocusTrap} coverScreen={fullscreen} style={modalStyle} deviceHeight={windowHeight} diff --git a/src/components/Modal/ReanimatedModal/index.tsx b/src/components/Modal/ReanimatedModal/index.tsx index e2764483a21b..8fe4ee85f9e0 100644 --- a/src/components/Modal/ReanimatedModal/index.tsx +++ b/src/components/Modal/ReanimatedModal/index.tsx @@ -36,6 +36,7 @@ function ReanimatedModal({ coverScreen = true, children, hasBackdrop = true, + shouldDisableFocusTrap = false, backdropColor = 'black', backdropOpacity = variables.overlayOpacity, customBackdrop = null, @@ -256,7 +257,7 @@ function ReanimatedModal({ ) : ( void; +}; + +type UseDateSegmentInputResult = { + /** The text to render in the input */ + displayValue: string; + + /** The range covering the segment being edited, which selects it as a whole */ + selection: DateSegmentRange | undefined; + + onKeyPress: (event: TextInputKeyPressEvent) => void; + onSelectionChange: (event: TextInputSelectionChangeEvent) => void; + onChangeText: (text: string) => void; + onFocus: () => void; + onBlur: () => void; +}; + +function isStepKey(key: string): key is keyof typeof STEP_KEYS { + return key in STEP_KEYS; +} + +function isMoveKey(key: string): key is keyof typeof MOVE_KEYS { + return key in MOVE_KEYS; +} + +/** Whether the key is a single character that is not a digit, such as a dash the user types to leave a segment */ +function isSeparatorKey(key: string): boolean { + return key.length === 1 && !isNumeric(key); +} + +export default function useDateSegmentInput({value, mask, isEnabled, onCommit}: UseDateSegmentInputParams): UseDateSegmentInputResult { + // The segments only describe an edit in progress, so they are seeded on focus rather than synced with the value + const [segments, setSegments] = useState(EMPTY_SEGMENTS); + const [activeSegmentName, setActiveSegmentName] = useState(FIRST_SEGMENT_NAME); + const [isEditing, setIsEditing] = useState(false); + + const {value: editingValue, ranges} = getDateDisplay(segments, mask); + // A caret parked at the start of the segment reads as three fields sharing one box. Selecting the whole segment + // instead would highlight it as a block, which is not what the design asks for. + const activeSegmentStart = ranges[activeSegmentName].start; + + const commitIfComplete = (newSegments: DateSegments) => { + const isoDate = getISODateFromSegments(newSegments); + if (!isoDate) { + return; + } + + onCommit(isoDate); + }; + + const applySegments = (newSegments: DateSegments) => { + setSegments(newSegments); + commitIfComplete(newSegments); + }; + + const handleKeyPress = (event: TextInputKeyPressEvent) => { + const key = event.nativeEvent.key; + + if (isNumeric(key)) { + event.preventDefault(); + const result = typeDigitIntoSegment(segments, activeSegmentName, key); + applySegments(result.segments); + + if (result.isSegmentComplete) { + setActiveSegmentName(getAdjacentSegmentName(activeSegmentName, 1)); + } + return; + } + + if (isStepKey(key)) { + event.preventDefault(); + applySegments(stepSegment(segments, activeSegmentName, STEP_KEYS[key])); + return; + } + + if (isMoveKey(key)) { + event.preventDefault(); + setActiveSegmentName(getAdjacentSegmentName(activeSegmentName, MOVE_KEYS[key])); + return; + } + + if (key === BACKSPACE_KEY || key === DELETE_KEY) { + event.preventDefault(); + setSegments(clearSegment(segments, activeSegmentName)); + return; + } + + if (!isSeparatorKey(key)) { + return; + } + + // A separator means the user is finished with this segment even if they only typed one digit into it + event.preventDefault(); + setActiveSegmentName(getAdjacentSegmentName(activeSegmentName, 1)); + }; + + // Clicking into the text lands the caret anywhere, so snap the selection out to whichever segment was clicked + const handleSelectionChange = (event: TextInputSelectionChangeEvent) => { + const clickedSegmentName = getSegmentNameAtPosition(event.nativeEvent.selection.start, ranges); + if (clickedSegmentName === activeSegmentName) { + return; + } + + setActiveSegmentName(clickedSegmentName); + }; + + // Every keystroke is prevented, so this only runs for text the user pasted in + const handleChangeText = (text: string) => { + const pastedSegments = getSegmentsFromText(text); + if (!hasAnySegment(pastedSegments)) { + return; + } + + applySegments(pastedSegments); + }; + + const handleFocus = () => { + setSegments(getSegmentsFromISODate(value)); + setActiveSegmentName(FIRST_SEGMENT_NAME); + setIsEditing(true); + }; + + // An unfinished edit is dropped rather than cleared, so leaving the field restores the last committed date + const handleBlur = () => { + setIsEditing(false); + setSegments(EMPTY_SEGMENTS); + }; + + if (!isEnabled) { + return { + displayValue: value, + selection: undefined, + onKeyPress: () => {}, + onSelectionChange: () => {}, + onChangeText: () => {}, + onFocus: () => {}, + onBlur: () => {}, + }; + } + + return { + displayValue: isEditing ? editingValue : value, + selection: isEditing ? {start: activeSegmentStart, end: activeSegmentStart} : undefined, + onKeyPress: handleKeyPress, + onSelectionChange: handleSelectionChange, + onChangeText: handleChangeText, + onFocus: handleFocus, + onBlur: handleBlur, + }; +} + +export type {UseDateSegmentInputParams, UseDateSegmentInputResult}; diff --git a/src/libs/DateInputMaskUtils.ts b/src/libs/DateInputMaskUtils.ts new file mode 100644 index 000000000000..ad790e1fe2fc --- /dev/null +++ b/src/libs/DateInputMaskUtils.ts @@ -0,0 +1,279 @@ +/** + * Helpers for the guided date input, where the year, month and day are edited as separate segments. The segments hold + * the digits the user has typed rather than a parsed date, so a half finished segment is representable and the + * display text can be rebuilt from them at any point. + */ +import CONST from '@src/CONST'; + +import type {TupleToUnion} from 'type-fest'; + +import {getDaysInMonth, isValid, parse} from 'date-fns'; + +const DATE_SEGMENT_NAMES = ['year', 'month', 'day'] as const; + +const YEAR_LENGTH = 4; +const SEGMENT_LENGTH = 2; + +const FIRST_MONTH = 1; +const LAST_MONTH = 12; +const FIRST_DAY = 1; + +/** The longest month, used as the day limit until the typed month says otherwise */ +const MAX_DAYS_IN_MONTH = 31; + +/** A leading month digit above this cannot start a two digit month, so the segment is zero padded and completed early */ +const MAX_LEADING_MONTH_DIGIT = 1; + +/** A leading day digit above this cannot start a two digit day, so the segment is zero padded and completed early */ +const MAX_LEADING_DAY_DIGIT = 3; + +/** Mask characters standing in for a digit are letters, so anything else is a separator to copy through verbatim */ +const MASK_LETTER_REGEX = /\p{L}/u; + +const NON_DIGIT_REGEX = /\D/g; + +type DateSegmentName = TupleToUnion; + +/** The digits typed into each segment, empty when the segment has not been filled in yet */ +type DateSegments = Record; + +type DateSegmentRange = { + start: number; + end: number; +}; + +type DateMaskPart = { + name: DateSegmentName; + + /** The mask letters shown while the segment is empty, such as YYYY */ + placeholder: string; + + /** The characters the mask puts after this segment, such as a dash */ + separator: string; +}; + +type DateDisplay = { + /** The text to show in the input */ + value: string; + + /** Where each segment sits inside that text, so a segment can be selected as a whole */ + ranges: Record; +}; + +const EMPTY_SEGMENTS: DateSegments = {year: '', month: '', day: ''}; + +function isMaskLetter(character: string): boolean { + return MASK_LETTER_REGEX.test(character); +} + +/** + * Reads the localized mask into one part per segment. Every locale writes the segments in year, month, day order, so + * the runs of letters are matched to the segments by position. + */ +function getDateMaskParts(mask: string): DateMaskPart[] { + const parts: DateMaskPart[] = []; + let index = 0; + + while (index < mask.length && parts.length < DATE_SEGMENT_NAMES.length) { + let placeholder = ''; + while (index < mask.length && isMaskLetter(mask.charAt(index))) { + placeholder += mask.charAt(index); + index++; + } + + if (!placeholder) { + break; + } + + let separator = ''; + while (index < mask.length && !isMaskLetter(mask.charAt(index))) { + separator += mask.charAt(index); + index++; + } + + parts.push({name: DATE_SEGMENT_NAMES[parts.length], placeholder, separator}); + } + + return parts; +} + +function getSegmentLength(name: DateSegmentName): number { + return name === 'year' ? YEAR_LENGTH : SEGMENT_LENGTH; +} + +function getDaysInTypedMonth(segments: DateSegments): number { + if (segments.month.length !== SEGMENT_LENGTH || segments.year.length !== YEAR_LENGTH) { + return MAX_DAYS_IN_MONTH; + } + + return getDaysInMonth(new Date(Number(segments.year), Number(segments.month) - 1)); +} + +/** Trims a day the newly typed year or month cannot have, so February never keeps a 30th from the month before */ +function withDayInMonth(segments: DateSegments): DateSegments { + if (segments.day.length !== SEGMENT_LENGTH) { + return segments; + } + + const daysInMonth = getDaysInTypedMonth(segments); + return Number(segments.day) > daysInMonth ? {...segments, day: String(daysInMonth).padStart(SEGMENT_LENGTH, '0')} : segments; +} + +function getDateDisplay(segments: DateSegments, mask: string): DateDisplay { + let value = ''; + const ranges: Record = {year: {start: 0, end: 0}, month: {start: 0, end: 0}, day: {start: 0, end: 0}}; + + for (const part of getDateMaskParts(mask)) { + const text = segments[part.name] || part.placeholder; + + ranges[part.name] = {start: value.length, end: value.length + text.length}; + value += `${text}${part.separator}`; + } + + return {value, ranges}; +} + +/** Which segment a caret position falls in, so clicking into the text selects the segment that was clicked */ +function getSegmentNameAtPosition(position: number, ranges: Record): DateSegmentName { + const name = DATE_SEGMENT_NAMES.find((segmentName) => position <= ranges[segmentName].end); + + return name ?? DATE_SEGMENT_NAMES[DATE_SEGMENT_NAMES.length - 1]; +} + +/** The segment `offset` places away, clamped so moving past either end keeps the outermost segment selected */ +function getAdjacentSegmentName(name: DateSegmentName, offset: number): DateSegmentName { + const nextIndex = DATE_SEGMENT_NAMES.indexOf(name) + offset; + const clampedIndex = Math.min(Math.max(nextIndex, 0), DATE_SEGMENT_NAMES.length - 1); + + return DATE_SEGMENT_NAMES[clampedIndex]; +} + +/** + * Adds one typed digit to a segment. A digit that cannot extend what is already there starts the segment over, which + * is what makes typing over a filled in date feel like overwriting it. + */ +function typeDigitIntoSegment(segments: DateSegments, name: DateSegmentName, digit: string): {segments: DateSegments; isSegmentComplete: boolean} { + const current = segments[name].length >= getSegmentLength(name) ? '' : segments[name]; + + if (name === 'year') { + // No year we support starts with a zero, so swallow the keystroke rather than start a year that cannot resolve + if (!current && digit === '0') { + return {segments, isSegmentComplete: false}; + } + + const year = `${current}${digit}`; + return {segments: withDayInMonth({...segments, year}), isSegmentComplete: year.length === YEAR_LENGTH}; + } + + const isMonth = name === 'month'; + const maxLeadingDigit = isMonth ? MAX_LEADING_MONTH_DIGIT : MAX_LEADING_DAY_DIGIT; + const lowest = isMonth ? FIRST_MONTH : FIRST_DAY; + const highest = isMonth ? LAST_MONTH : getDaysInTypedMonth(segments); + + if (!current) { + if (Number(digit) > maxLeadingDigit) { + const padded = `0${digit}`; + return {segments: withDayInMonth({...segments, [name]: padded}), isSegmentComplete: true}; + } + + return {segments: {...segments, [name]: digit}, isSegmentComplete: false}; + } + + const candidate = Number(`${current}${digit}`); + if (candidate >= lowest && candidate <= highest) { + return {segments: withDayInMonth({...segments, [name]: `${current}${digit}`}), isSegmentComplete: true}; + } + + // The two digit number is out of range, so treat the keystroke as the start of a new segment instead + return typeDigitIntoSegment({...segments, [name]: ''}, name, digit); +} + +/** Moves a segment up or down by `offset`, wrapping months and days and starting from today when the segment is empty */ +function stepSegment(segments: DateSegments, name: DateSegmentName, offset: number): DateSegments { + const today = new Date(); + + if (name === 'year') { + const current = segments.year.length === YEAR_LENGTH ? Number(segments.year) : today.getFullYear(); + const year = Math.min(Math.max(current + offset, CONST.CALENDAR_PICKER.MIN_YEAR), CONST.CALENDAR_PICKER.MAX_YEAR); + + return withDayInMonth({...segments, year: String(year)}); + } + + const isMonth = name === 'month'; + const highest = isMonth ? LAST_MONTH : getDaysInTypedMonth(segments); + const fallback = isMonth ? today.getMonth() + FIRST_MONTH : today.getDate(); + const current = segments[name].length === SEGMENT_LENGTH ? Number(segments[name]) : fallback; + const stepped = ((current - 1 + offset + highest) % highest) + 1; + + return withDayInMonth({...segments, [name]: String(stepped).padStart(SEGMENT_LENGTH, '0')}); +} + +function clearSegment(segments: DateSegments, name: DateSegmentName): DateSegments { + return {...segments, [name]: ''}; +} + +function getSegmentsFromISODate(value: string | undefined): DateSegments { + if (!value || !isValid(parse(value, CONST.DATE.FNS_FORMAT_STRING, new Date()))) { + return EMPTY_SEGMENTS; + } + + const digits = value.replaceAll(NON_DIGIT_REGEX, ''); + + return { + year: digits.slice(0, YEAR_LENGTH), + month: digits.slice(YEAR_LENGTH, YEAR_LENGTH + SEGMENT_LENGTH), + day: digits.slice(YEAR_LENGTH + SEGMENT_LENGTH), + }; +} + +function getNextUnfilledSegmentName(segments: DateSegments): DateSegmentName | undefined { + return DATE_SEGMENT_NAMES.find((name) => segments[name].length < getSegmentLength(name)); +} + +/** Fills the segments from arbitrary text, so pasting a date works without going through it a keystroke at a time */ +function getSegmentsFromText(text: string): DateSegments { + const digits = text.replaceAll(NON_DIGIT_REGEX, ''); + let filled = EMPTY_SEGMENTS; + + for (const digit of digits) { + const name = getNextUnfilledSegmentName(filled); + if (!name) { + break; + } + + filled = typeDigitIntoSegment(filled, name, digit).segments; + } + + return filled; +} + +/** Returns the date in the format the rest of the app stores, or undefined while any segment is still unfinished */ +function getISODateFromSegments(segments: DateSegments): string | undefined { + if (segments.year.length !== YEAR_LENGTH || segments.month.length !== SEGMENT_LENGTH || segments.day.length !== SEGMENT_LENGTH) { + return undefined; + } + + const isoDate = `${segments.year}-${segments.month}-${segments.day}`; + + return isValid(parse(isoDate, CONST.DATE.FNS_FORMAT_STRING, new Date())) ? isoDate : undefined; +} + +function hasAnySegment(segments: DateSegments): boolean { + return DATE_SEGMENT_NAMES.some((name) => !!segments[name]); +} + +export { + DATE_SEGMENT_NAMES, + EMPTY_SEGMENTS, + clearSegment, + getAdjacentSegmentName, + getDateDisplay, + getISODateFromSegments, + getSegmentNameAtPosition, + getSegmentsFromISODate, + getSegmentsFromText, + hasAnySegment, + stepSegment, + typeDigitIntoSegment, +}; +export type {DateSegmentName, DateSegmentRange, DateSegments}; diff --git a/src/libs/isTypedDateInputSupported/index.native.ts b/src/libs/isTypedDateInputSupported/index.native.ts new file mode 100644 index 000000000000..a831cc67c730 --- /dev/null +++ b/src/libs/isTypedDateInputSupported/index.native.ts @@ -0,0 +1,5 @@ +import type IsTypedDateInputSupported from './types'; + +const isTypedDateInputSupported: IsTypedDateInputSupported = () => false; + +export default isTypedDateInputSupported; diff --git a/src/libs/isTypedDateInputSupported/index.ts b/src/libs/isTypedDateInputSupported/index.ts new file mode 100644 index 000000000000..61021e14acc2 --- /dev/null +++ b/src/libs/isTypedDateInputSupported/index.ts @@ -0,0 +1,11 @@ +/** + * Whether a date field accepts typed input alongside the calendar picker. Touch devices keep the picker on its own so + * the soft keyboard does not cover the calendar. + */ +import {canUseTouchScreen} from '@libs/DeviceCapabilities'; + +import type IsTypedDateInputSupported from './types'; + +const isTypedDateInputSupported: IsTypedDateInputSupported = () => !canUseTouchScreen(); + +export default isTypedDateInputSupported; diff --git a/src/libs/isTypedDateInputSupported/types.ts b/src/libs/isTypedDateInputSupported/types.ts new file mode 100644 index 000000000000..02f08dc515a7 --- /dev/null +++ b/src/libs/isTypedDateInputSupported/types.ts @@ -0,0 +1,3 @@ +type IsTypedDateInputSupported = () => boolean; + +export default IsTypedDateInputSupported; diff --git a/tests/unit/DateInputMaskUtilsTest.ts b/tests/unit/DateInputMaskUtilsTest.ts new file mode 100644 index 000000000000..b930743e47b7 --- /dev/null +++ b/tests/unit/DateInputMaskUtilsTest.ts @@ -0,0 +1,206 @@ +import { + clearSegment, + getAdjacentSegmentName, + getDateDisplay, + getISODateFromSegments, + getSegmentNameAtPosition, + getSegmentsFromISODate, + getSegmentsFromText, + hasAnySegment, + stepSegment, + typeDigitIntoSegment, +} from '@libs/DateInputMaskUtils'; +import type {DateSegments} from '@libs/DateInputMaskUtils'; + +const MASK = 'YYYY-MM-DD'; +const EMPTY: DateSegments = {year: '', month: '', day: ''}; + +function segments(year: string, month: string, day: string): DateSegments { + return {year, month, day}; +} + +describe('DateInputMaskUtils', () => { + describe('typeDigitIntoSegment', () => { + it('rejects a leading zero in the year', () => { + expect(typeDigitIntoSegment(EMPTY, 'year', '0')).toEqual({segments: EMPTY, isSegmentComplete: false}); + }); + + it('completes the year on the fourth digit', () => { + expect(typeDigitIntoSegment(segments('202', '', ''), 'year', '6')).toEqual({segments: segments('2026', '', ''), isSegmentComplete: true}); + expect(typeDigitIntoSegment(segments('20', '', ''), 'year', '2')).toEqual({segments: segments('202', '', ''), isSegmentComplete: false}); + }); + + it('starts the year over when it is already full', () => { + expect(typeDigitIntoSegment(segments('2026', '', ''), 'year', '1')).toEqual({segments: segments('1', '', ''), isSegmentComplete: false}); + }); + + it('zero pads a month that cannot start a two digit month', () => { + expect(typeDigitIntoSegment(segments('2026', '', ''), 'month', '9')).toEqual({segments: segments('2026', '09', ''), isSegmentComplete: true}); + }); + + it('waits for a second digit when the month could still be a teen month', () => { + expect(typeDigitIntoSegment(segments('2026', '', ''), 'month', '1')).toEqual({segments: segments('2026', '1', ''), isSegmentComplete: false}); + expect(typeDigitIntoSegment(segments('2026', '1', ''), 'month', '2')).toEqual({segments: segments('2026', '12', ''), isSegmentComplete: true}); + }); + + it('restarts the month when the two digit number is out of range', () => { + expect(typeDigitIntoSegment(segments('2026', '1', ''), 'month', '3')).toEqual({segments: segments('2026', '03', ''), isSegmentComplete: true}); + expect(typeDigitIntoSegment(segments('2026', '0', ''), 'month', '0')).toEqual({segments: segments('2026', '0', ''), isSegmentComplete: false}); + }); + + it('limits the day to the typed month', () => { + expect(typeDigitIntoSegment(segments('2026', '01', '3'), 'day', '1')).toEqual({segments: segments('2026', '01', '31'), isSegmentComplete: true}); + + // February has no 31st, so the keystroke starts the day over instead + expect(typeDigitIntoSegment(segments('2026', '02', '3'), 'day', '1')).toEqual({segments: segments('2026', '02', '1'), isSegmentComplete: false}); + }); + + it('allows February 29 in a leap year only', () => { + expect(typeDigitIntoSegment(segments('2024', '02', '2'), 'day', '9')).toEqual({segments: segments('2024', '02', '29'), isSegmentComplete: true}); + expect(typeDigitIntoSegment(segments('2026', '02', '2'), 'day', '9')).toEqual({segments: segments('2026', '02', '09'), isSegmentComplete: true}); + }); + + it('allows any day up to 31 before the month is known', () => { + expect(typeDigitIntoSegment(segments('', '', '3'), 'day', '1')).toEqual({segments: segments('', '', '31'), isSegmentComplete: true}); + }); + + it('trims a day the newly typed month cannot have', () => { + expect(typeDigitIntoSegment(segments('2026', '1', '31'), 'month', '1').segments).toEqual(segments('2026', '11', '30')); + }); + + it('trims a day the newly typed year cannot have', () => { + expect(typeDigitIntoSegment(segments('202', '02', '29'), 'year', '6').segments).toEqual(segments('2026', '02', '28')); + }); + }); + + describe('stepSegment', () => { + it('wraps the month at both ends', () => { + expect(stepSegment(segments('2026', '12', ''), 'month', 1)).toEqual(segments('2026', '01', '')); + expect(stepSegment(segments('2026', '01', ''), 'month', -1)).toEqual(segments('2026', '12', '')); + }); + + it('wraps the day within the typed month', () => { + expect(stepSegment(segments('2026', '02', '28'), 'day', 1)).toEqual(segments('2026', '02', '01')); + }); + + it('steps the year without wrapping', () => { + expect(stepSegment(segments('2026', '', ''), 'year', 1)).toEqual(segments('2027', '', '')); + }); + + it('trims the day when stepping into a shorter month', () => { + expect(stepSegment(segments('2026', '01', '31'), 'month', 1)).toEqual(segments('2026', '02', '28')); + }); + }); + + describe('getDateDisplay', () => { + it('shows the mask while every segment is empty', () => { + expect(getDateDisplay(EMPTY, MASK).value).toBe(MASK); + }); + + it('keeps the mask for the segments still to be filled in', () => { + expect(getDateDisplay(segments('2026', '', ''), MASK).value).toBe('2026-MM-DD'); + expect(getDateDisplay(segments('2026', '09', ''), MASK).value).toBe('2026-09-DD'); + expect(getDateDisplay(segments('2026', '09', '18'), MASK).value).toBe('2026-09-18'); + }); + + it('reports where each segment sits in the text', () => { + expect(getDateDisplay(segments('2026', '09', '18'), MASK).ranges).toEqual({ + year: {start: 0, end: 4}, + month: {start: 5, end: 7}, + day: {start: 8, end: 10}, + }); + }); + + it('shifts the later ranges when a segment holds a single digit', () => { + const {value, ranges} = getDateDisplay(segments('2026', '1', ''), MASK); + + expect(value).toBe('2026-1-DD'); + expect(ranges.month).toEqual({start: 5, end: 6}); + expect(ranges.day).toEqual({start: 7, end: 9}); + }); + + it('uses the letters and separators of the localized mask', () => { + expect(getDateDisplay(segments('2026', '', ''), 'AAAA-MM-JJ').value).toBe('2026-MM-JJ'); + }); + }); + + describe('getSegmentNameAtPosition', () => { + const {ranges} = getDateDisplay(segments('2026', '09', '18'), MASK); + + it('maps a position to the segment that covers it', () => { + expect(getSegmentNameAtPosition(0, ranges)).toBe('year'); + expect(getSegmentNameAtPosition(4, ranges)).toBe('year'); + expect(getSegmentNameAtPosition(6, ranges)).toBe('month'); + expect(getSegmentNameAtPosition(9, ranges)).toBe('day'); + }); + + it('falls back to the last segment past the end of the text', () => { + expect(getSegmentNameAtPosition(99, ranges)).toBe('day'); + }); + }); + + describe('getAdjacentSegmentName', () => { + it('moves between segments', () => { + expect(getAdjacentSegmentName('year', 1)).toBe('month'); + expect(getAdjacentSegmentName('month', -1)).toBe('year'); + }); + + it('stays on the outermost segment rather than wrapping', () => { + expect(getAdjacentSegmentName('year', -1)).toBe('year'); + expect(getAdjacentSegmentName('day', 1)).toBe('day'); + }); + }); + + describe('getSegmentsFromText', () => { + it('fills the segments from a pasted date', () => { + expect(getSegmentsFromText('2026-09-18')).toEqual(segments('2026', '09', '18')); + expect(getSegmentsFromText('20260918')).toEqual(segments('2026', '09', '18')); + }); + + it('stops once every segment is full', () => { + expect(getSegmentsFromText('20260918123')).toEqual(segments('2026', '09', '18')); + }); + + it('fills what it can from a partial date', () => { + expect(getSegmentsFromText('2026-09')).toEqual(segments('2026', '09', '')); + expect(getSegmentsFromText('')).toEqual(EMPTY); + }); + }); + + describe('getISODateFromSegments', () => { + it('returns the stored format once every segment is filled in', () => { + expect(getISODateFromSegments(segments('2026', '09', '18'))).toBe('2026-09-18'); + }); + + it('returns undefined while a segment is unfinished', () => { + expect(getISODateFromSegments(segments('2026', '9', '18'))).toBeUndefined(); + expect(getISODateFromSegments(segments('202', '09', '18'))).toBeUndefined(); + expect(getISODateFromSegments(EMPTY)).toBeUndefined(); + }); + }); + + describe('getSegmentsFromISODate', () => { + it('reads back a stored date', () => { + expect(getSegmentsFromISODate('2026-09-18')).toEqual(segments('2026', '09', '18')); + }); + + it('returns empty segments for a value it cannot parse', () => { + expect(getSegmentsFromISODate('')).toEqual(EMPTY); + expect(getSegmentsFromISODate(undefined)).toEqual(EMPTY); + expect(getSegmentsFromISODate('not a date')).toEqual(EMPTY); + }); + }); + + describe('clearSegment', () => { + it('empties only the named segment', () => { + expect(clearSegment(segments('2026', '09', '18'), 'month')).toEqual(segments('2026', '', '18')); + }); + }); + + describe('hasAnySegment', () => { + it('reports whether anything has been filled in', () => { + expect(hasAnySegment(EMPTY)).toBe(false); + expect(hasAnySegment(segments('', '09', ''))).toBe(true); + }); + }); +}); From 79a94e838f83273dc8396bc1bec923ba7323047f Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 17 Sep 2026 20:29:06 +0530 Subject: [PATCH 02/23] fix: reach the overlay-free popover from a narrow pane, and open the calendar on focus Signed-off-by: krishna2323 --- src/components/DatePicker/DatePickerModal.tsx | 8 ++++---- src/components/DatePicker/index.tsx | 17 ++++++++++++++--- src/components/Modal/BaseModal.tsx | 2 -- src/components/Modal/ReanimatedModal/index.tsx | 3 +-- src/components/Modal/ReanimatedModal/types.ts | 6 ------ src/components/Popover/index.tsx | 7 ++++++- src/components/Popover/types.ts | 7 +++++++ 7 files changed, 32 insertions(+), 18 deletions(-) diff --git a/src/components/DatePicker/DatePickerModal.tsx b/src/components/DatePicker/DatePickerModal.tsx index d6f0d12a52d1..0b0f76f4de9b 100644 --- a/src/components/DatePicker/DatePickerModal.tsx +++ b/src/components/DatePicker/DatePickerModal.tsx @@ -47,8 +47,8 @@ function DatePickerModal({ forwardedFSClass, shouldEnableMonthYearBackdropInNarrowPane = false, anchorRef: anchorRefProp, - hasBackdrop, - shouldDisableFocusTrap = false, + withoutOverlay = false, + shouldAllowWithoutOverlayInNarrowPane = false, }: DatePickerProps) { const [selectedDate, setSelectedDate] = useState(value ?? defaultValue ?? undefined); const fallbackAnchorRef = useRef(null); @@ -101,8 +101,8 @@ function DatePickerModal({ forwardedFSClass={forwardedFSClass} shouldDisplayBelowModals enableEdgeToEdgeBottomSafeAreaPadding - hasBackdrop={hasBackdrop} - shouldDisableFocusTrap={shouldDisableFocusTrap} + withoutOverlay={withoutOverlay} + shouldAllowWithoutOverlayInNarrowPane={shouldAllowWithoutOverlayInNarrowPane} > { + segmentInput.onFocus(); + + if (isModalVisible) { + return; + } + + showDatePickerModal(); + }; + const handleInputKeyPress = useCallback( (event: TextInputKeyPressEvent) => { if (!isNumeric(event.nativeEvent.key)) { @@ -272,7 +283,7 @@ function DatePicker({ hideFocusedState={shouldDismissKeyboardBeforeShow && !shouldAllowTyping} onPress={shouldDismissKeyboardBeforeShow || shouldAllowTyping ? handlePress : () => showDatePickerModal()} onSubmitEditing={shouldAllowTyping ? undefined : () => showDatePickerModal()} - onFocus={shouldAllowTyping ? segmentInput.onFocus : undefined} + onFocus={shouldAllowTyping ? handleFocus : undefined} onBlur={shouldAllowTyping ? segmentInput.onBlur : undefined} onChangeText={shouldAllowTyping ? segmentInput.onChangeText : undefined} onSelectionChange={shouldAllowTyping ? segmentInput.onSelectionChange : undefined} @@ -300,8 +311,8 @@ function DatePicker({ forwardedFSClass={forwardedFSClass} shouldCloseWhenBrowserNavigationChanged anchorRef={anchorRef} - hasBackdrop={shouldAllowTyping ? false : undefined} - shouldDisableFocusTrap={shouldAllowTyping} + withoutOverlay={shouldAllowTyping} + shouldAllowWithoutOverlayInNarrowPane={shouldAllowTyping} /> ); diff --git a/src/components/Modal/BaseModal.tsx b/src/components/Modal/BaseModal.tsx index 5cb6c2f92f7e..12c5ec70c794 100644 --- a/src/components/Modal/BaseModal.tsx +++ b/src/components/Modal/BaseModal.tsx @@ -77,7 +77,6 @@ function BaseModal({ enableEdgeToEdgeBottomSafeAreaPadding, shouldApplySidePanelOffset: shouldApplySidePanelOffsetProp, hasBackdrop, - shouldDisableFocusTrap = false, backdropOpacity, shouldDisableBottomSafeAreaPadding = false, shouldIgnoreBackHandlerDuringTransition = false, @@ -370,7 +369,6 @@ function BaseModal({ backdropOpacity={backdropOpacityAdjusted} backdropTransitionOutTiming={0} hasBackdrop={hasBackdrop ?? fullscreen} - shouldDisableFocusTrap={shouldDisableFocusTrap} coverScreen={fullscreen} style={modalStyle} deviceHeight={windowHeight} diff --git a/src/components/Modal/ReanimatedModal/index.tsx b/src/components/Modal/ReanimatedModal/index.tsx index 8fe4ee85f9e0..e2764483a21b 100644 --- a/src/components/Modal/ReanimatedModal/index.tsx +++ b/src/components/Modal/ReanimatedModal/index.tsx @@ -36,7 +36,6 @@ function ReanimatedModal({ coverScreen = true, children, hasBackdrop = true, - shouldDisableFocusTrap = false, backdropColor = 'black', backdropOpacity = variables.overlayOpacity, customBackdrop = null, @@ -257,7 +256,7 @@ function ReanimatedModal({ ) : ( {}, animationIn = 'fadeIn', @@ -133,7 +134,11 @@ function Popover(props: PopoverProps) { ); } - if (withoutOverlay && !shouldUseNarrowLayout) { + // A narrow pane normally forces the full modal. An opting-in caller keeps the overlay-free popover there, but a + // small screen never does, since there is no room for a popover beside the control that opened it. + const canSkipOverlay = !shouldUseNarrowLayout || (shouldAllowWithoutOverlayInNarrowPane && !isSmallScreenWidth); + + if (withoutOverlay && canSkipOverlay) { return createPortal( ; From de3a085a6e63ec251076b7d464222bc426f17504 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 17 Sep 2026 20:43:54 +0530 Subject: [PATCH 03/23] fix: keep the date mask a fixed width and track the caret per digit Signed-off-by: krishna2323 --- src/hooks/useDateSegmentInput.ts | 75 ++++++++++++++++++++++------ src/libs/DateInputMaskUtils.ts | 5 +- tests/unit/DateInputMaskUtilsTest.ts | 15 ++++-- 3 files changed, 73 insertions(+), 22 deletions(-) diff --git a/src/hooks/useDateSegmentInput.ts b/src/hooks/useDateSegmentInput.ts index 788cf73e1e0f..8d77746a2819 100644 --- a/src/hooks/useDateSegmentInput.ts +++ b/src/hooks/useDateSegmentInput.ts @@ -21,7 +21,7 @@ import {isNumeric} from '@libs/ValidationUtils'; import type {TextInputKeyPressEvent, TextInputSelectionChangeEvent} from 'react-native'; -import {useState} from 'react'; +import {useRef, useState} from 'react'; const FIRST_SEGMENT_NAME = DATE_SEGMENT_NAMES[0]; @@ -75,12 +75,29 @@ export default function useDateSegmentInput({value, mask, isEnabled, onCommit}: // The segments only describe an edit in progress, so they are seeded on focus rather than synced with the value const [segments, setSegments] = useState(EMPTY_SEGMENTS); const [activeSegmentName, setActiveSegmentName] = useState(FIRST_SEGMENT_NAME); + const [caretOffset, setCaretOffset] = useState(0); const [isEditing, setIsEditing] = useState(false); + // Re-rendering with a new value makes the browser report a caret of its own choosing. Honouring that would drag + // the active segment around, so the first report after a keystroke is discarded as an echo of our own update. + const hasPendingCaretEchoRef = useRef(false); const {value: editingValue, ranges} = getDateDisplay(segments, mask); - // A caret parked at the start of the segment reads as three fields sharing one box. Selecting the whole segment - // instead would highlight it as a block, which is not what the design asks for. - const activeSegmentStart = ranges[activeSegmentName].start; + const activeRange = ranges[activeSegmentName]; + // A caret parked on a digit place reads as three fields sharing one box. Selecting the whole segment instead would + // highlight it as a block, which is not what the design asks for. + const caretPosition = activeRange.start + caretOffset; + + /** + * A caret may rest on any digit place already typed, or just after the last of them, but never out on a mask + * letter. An empty segment therefore only ever has its start, which is what stops a click landing on a bare Y. + */ + const getFurthestOffset = (name: DateSegmentName, currentSegments: DateSegments) => currentSegments[name].length; + + const moveCaret = (name: DateSegmentName, offset: number, nextSegments: DateSegments = segments) => { + hasPendingCaretEchoRef.current = true; + setActiveSegmentName(name); + setCaretOffset(Math.min(Math.max(offset, 0), getFurthestOffset(name, nextSegments))); + }; const commitIfComplete = (newSegments: DateSegments) => { const isoDate = getISODateFromSegments(newSegments); @@ -104,27 +121,47 @@ export default function useDateSegmentInput({value, mask, isEnabled, onCommit}: const result = typeDigitIntoSegment(segments, activeSegmentName, key); applySegments(result.segments); - if (result.isSegmentComplete) { - setActiveSegmentName(getAdjacentSegmentName(activeSegmentName, 1)); - } + // A completed segment hands over to its neighbour. The last one has nowhere to hand over to, so the caret + // rests after the digit just typed and the next digit overwrites the segment. + const nextSegmentName = result.isSegmentComplete ? getAdjacentSegmentName(activeSegmentName, 1) : activeSegmentName; + const nextOffset = nextSegmentName === activeSegmentName ? result.segments[activeSegmentName].length : 0; + + moveCaret(nextSegmentName, nextOffset, result.segments); return; } if (isStepKey(key)) { event.preventDefault(); - applySegments(stepSegment(segments, activeSegmentName, STEP_KEYS[key])); + const steppedSegments = stepSegment(segments, activeSegmentName, STEP_KEYS[key]); + applySegments(steppedSegments); + moveCaret(activeSegmentName, caretOffset, steppedSegments); return; } if (isMoveKey(key)) { event.preventDefault(); - setActiveSegmentName(getAdjacentSegmentName(activeSegmentName, MOVE_KEYS[key])); + const step = MOVE_KEYS[key]; + const nextOffset = caretOffset + step; + + // Stepping past either end of what has been typed carries on into the neighbouring segment. + if (nextOffset >= 0 && nextOffset <= getFurthestOffset(activeSegmentName, segments)) { + moveCaret(activeSegmentName, nextOffset); + return; + } + + const adjacentSegmentName = getAdjacentSegmentName(activeSegmentName, step); + if (adjacentSegmentName === activeSegmentName) { + return; + } + + moveCaret(adjacentSegmentName, step > 0 ? 0 : getFurthestOffset(adjacentSegmentName, segments)); return; } if (key === BACKSPACE_KEY || key === DELETE_KEY) { event.preventDefault(); setSegments(clearSegment(segments, activeSegmentName)); + moveCaret(activeSegmentName, 0); return; } @@ -134,17 +171,21 @@ export default function useDateSegmentInput({value, mask, isEnabled, onCommit}: // A separator means the user is finished with this segment even if they only typed one digit into it event.preventDefault(); - setActiveSegmentName(getAdjacentSegmentName(activeSegmentName, 1)); + moveCaret(getAdjacentSegmentName(activeSegmentName, 1), 0); }; - // Clicking into the text lands the caret anywhere, so snap the selection out to whichever segment was clicked + // Clicking into the text lands the caret anywhere, so snap it onto the digit place that was clicked const handleSelectionChange = (event: TextInputSelectionChangeEvent) => { - const clickedSegmentName = getSegmentNameAtPosition(event.nativeEvent.selection.start, ranges); - if (clickedSegmentName === activeSegmentName) { + if (hasPendingCaretEchoRef.current) { + hasPendingCaretEchoRef.current = false; return; } - setActiveSegmentName(clickedSegmentName); + const position = event.nativeEvent.selection.start; + const clickedSegmentName = getSegmentNameAtPosition(position, ranges); + const clickedOffset = position - ranges[clickedSegmentName].start; + + moveCaret(clickedSegmentName, clickedOffset); }; // Every keystroke is prevented, so this only runs for text the user pasted in @@ -155,11 +196,12 @@ export default function useDateSegmentInput({value, mask, isEnabled, onCommit}: } applySegments(pastedSegments); + moveCaret(FIRST_SEGMENT_NAME, 0); }; const handleFocus = () => { setSegments(getSegmentsFromISODate(value)); - setActiveSegmentName(FIRST_SEGMENT_NAME); + moveCaret(FIRST_SEGMENT_NAME, 0); setIsEditing(true); }; @@ -167,6 +209,7 @@ export default function useDateSegmentInput({value, mask, isEnabled, onCommit}: const handleBlur = () => { setIsEditing(false); setSegments(EMPTY_SEGMENTS); + setCaretOffset(0); }; if (!isEnabled) { @@ -183,7 +226,7 @@ export default function useDateSegmentInput({value, mask, isEnabled, onCommit}: return { displayValue: isEditing ? editingValue : value, - selection: isEditing ? {start: activeSegmentStart, end: activeSegmentStart} : undefined, + selection: isEditing ? {start: caretPosition, end: caretPosition} : undefined, onKeyPress: handleKeyPress, onSelectionChange: handleSelectionChange, onChangeText: handleChangeText, diff --git a/src/libs/DateInputMaskUtils.ts b/src/libs/DateInputMaskUtils.ts index ad790e1fe2fc..0e60781d9673 100644 --- a/src/libs/DateInputMaskUtils.ts +++ b/src/libs/DateInputMaskUtils.ts @@ -124,7 +124,10 @@ function getDateDisplay(segments: DateSegments, mask: string): DateDisplay { const ranges: Record = {year: {start: 0, end: 0}, month: {start: 0, end: 0}, day: {start: 0, end: 0}}; for (const part of getDateMaskParts(mask)) { - const text = segments[part.name] || part.placeholder; + // Typed digits replace the mask letters one at a time, so a half typed year reads as 2YYY rather than 2. This + // keeps every segment the width of its mask, which is what lets a caret position mean the same thing twice. + const digits = segments[part.name].slice(0, part.placeholder.length); + const text = `${digits}${part.placeholder.slice(digits.length)}`; ranges[part.name] = {start: value.length, end: value.length + text.length}; value += `${text}${part.separator}`; diff --git a/tests/unit/DateInputMaskUtilsTest.ts b/tests/unit/DateInputMaskUtilsTest.ts index b930743e47b7..6b113facbe87 100644 --- a/tests/unit/DateInputMaskUtilsTest.ts +++ b/tests/unit/DateInputMaskUtilsTest.ts @@ -103,6 +103,12 @@ describe('DateInputMaskUtils', () => { expect(getDateDisplay(segments('2026', '09', '18'), MASK).value).toBe('2026-09-18'); }); + it('keeps the mask letters of the digit places a half typed segment has not reached', () => { + expect(getDateDisplay(segments('2', '', ''), MASK).value).toBe('2YYY-MM-DD'); + expect(getDateDisplay(segments('20', '', ''), MASK).value).toBe('20YY-MM-DD'); + expect(getDateDisplay(segments('2026', '1', ''), MASK).value).toBe('2026-1M-DD'); + }); + it('reports where each segment sits in the text', () => { expect(getDateDisplay(segments('2026', '09', '18'), MASK).ranges).toEqual({ year: {start: 0, end: 4}, @@ -111,12 +117,11 @@ describe('DateInputMaskUtils', () => { }); }); - it('shifts the later ranges when a segment holds a single digit', () => { - const {value, ranges} = getDateDisplay(segments('2026', '1', ''), MASK); + it('holds the ranges still while a segment is half typed, so a caret position keeps its meaning', () => { + const {ranges} = getDateDisplay(segments('2026', '1', ''), MASK); - expect(value).toBe('2026-1-DD'); - expect(ranges.month).toEqual({start: 5, end: 6}); - expect(ranges.day).toEqual({start: 7, end: 9}); + expect(ranges.month).toEqual({start: 5, end: 7}); + expect(ranges.day).toEqual({start: 8, end: 10}); }); it('uses the letters and separators of the localized mask', () => { From 59e4711538cab4f6a11ff72a6a65e8f3ab191f49 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 17 Sep 2026 21:16:37 +0530 Subject: [PATCH 04/23] fix: match the prototype's segment rules for typed dates Signed-off-by: krishna2323 --- src/hooks/useDateSegmentInput.ts | 98 ++++++++-------- src/libs/DateInputMaskUtils.ts | 160 +++++++++++++++------------ tests/unit/DateInputMaskUtilsTest.ts | 85 ++++++-------- 3 files changed, 172 insertions(+), 171 deletions(-) diff --git a/src/hooks/useDateSegmentInput.ts b/src/hooks/useDateSegmentInput.ts index 8d77746a2819..958cbc8106a1 100644 --- a/src/hooks/useDateSegmentInput.ts +++ b/src/hooks/useDateSegmentInput.ts @@ -5,7 +5,6 @@ import { DATE_SEGMENT_NAMES, EMPTY_SEGMENTS, - clearSegment, getAdjacentSegmentName, getDateDisplay, getISODateFromSegments, @@ -13,8 +12,8 @@ import { getSegmentsFromISODate, getSegmentsFromText, hasAnySegment, - stepSegment, - typeDigitIntoSegment, + removeLastDigit, + typeDigitIntoSegments, } from '@libs/DateInputMaskUtils'; import type {DateSegmentName, DateSegmentRange, DateSegments} from '@libs/DateInputMaskUtils'; import {isNumeric} from '@libs/ValidationUtils'; @@ -24,12 +23,15 @@ import type {TextInputKeyPressEvent, TextInputSelectionChangeEvent} from 'react- import {useRef, useState} from 'react'; const FIRST_SEGMENT_NAME = DATE_SEGMENT_NAMES[0]; +const LAST_SEGMENT_NAME = DATE_SEGMENT_NAMES[DATE_SEGMENT_NAMES.length - 1]; const BACKSPACE_KEY = 'Backspace'; const DELETE_KEY = 'Delete'; -const STEP_KEYS = {ArrowUp: 1, ArrowDown: -1} as const; const MOVE_KEYS = {ArrowLeft: -1, ArrowRight: 1} as const; +/** The characters a locale uses between segments, any of which means the user is finished with the one they are on */ +const SEPARATOR_KEYS = new Set(['-', '/', '.', ' ']); + type UseDateSegmentInputParams = { /** The committed date in the format the app stores, shown whenever the field is not being edited */ value: string; @@ -58,19 +60,10 @@ type UseDateSegmentInputResult = { onBlur: () => void; }; -function isStepKey(key: string): key is keyof typeof STEP_KEYS { - return key in STEP_KEYS; -} - function isMoveKey(key: string): key is keyof typeof MOVE_KEYS { return key in MOVE_KEYS; } -/** Whether the key is a single character that is not a digit, such as a dash the user types to leave a segment */ -function isSeparatorKey(key: string): boolean { - return key.length === 1 && !isNumeric(key); -} - export default function useDateSegmentInput({value, mask, isEnabled, onCommit}: UseDateSegmentInputParams): UseDateSegmentInputResult { // The segments only describe an edit in progress, so they are seeded on focus rather than synced with the value const [segments, setSegments] = useState(EMPTY_SEGMENTS); @@ -80,6 +73,8 @@ export default function useDateSegmentInput({value, mask, isEnabled, onCommit}: // Re-rendering with a new value makes the browser report a caret of its own choosing. Honouring that would drag // the active segment around, so the first report after a keystroke is discarded as an echo of our own update. const hasPendingCaretEchoRef = useRef(false); + // Whether the next digit replaces the active segment instead of extending it, set on arriving at a segment + const shouldOverwriteRef = useRef(false); const {value: editingValue, ranges} = getDateDisplay(segments, mask); const activeRange = ranges[activeSegmentName]; @@ -99,6 +94,16 @@ export default function useDateSegmentInput({value, mask, isEnabled, onCommit}: setCaretOffset(Math.min(Math.max(offset, 0), getFurthestOffset(name, nextSegments))); }; + /** + * Landing on a segment always rests the caret after whatever it already holds, so an empty one reads from its + * start and a filled one is ready to be typed over. `shouldOverwriteRef` is what makes that typing replace the + * segment rather than extend it. + */ + const enterSegment = (name: DateSegmentName, nextSegments: DateSegments = segments) => { + shouldOverwriteRef.current = true; + moveCaret(name, getFurthestOffset(name, nextSegments), nextSegments); + }; + const commitIfComplete = (newSegments: DateSegments) => { const isoDate = getISODateFromSegments(newSegments); if (!isoDate) { @@ -118,60 +123,52 @@ export default function useDateSegmentInput({value, mask, isEnabled, onCommit}: if (isNumeric(key)) { event.preventDefault(); - const result = typeDigitIntoSegment(segments, activeSegmentName, key); + const result = typeDigitIntoSegments(segments, activeSegmentName, key, shouldOverwriteRef.current); + shouldOverwriteRef.current = false; applySegments(result.segments); - // A completed segment hands over to its neighbour. The last one has nowhere to hand over to, so the caret - // rests after the digit just typed and the next digit overwrites the segment. - const nextSegmentName = result.isSegmentComplete ? getAdjacentSegmentName(activeSegmentName, 1) : activeSegmentName; - const nextOffset = nextSegmentName === activeSegmentName ? result.segments[activeSegmentName].length : 0; + if (result.nextSegmentName) { + enterSegment(result.nextSegmentName, result.segments); + return; + } - moveCaret(nextSegmentName, nextOffset, result.segments); + moveCaret(activeSegmentName, result.segments[activeSegmentName].length, result.segments); return; } - if (isStepKey(key)) { + if (isMoveKey(key)) { event.preventDefault(); - const steppedSegments = stepSegment(segments, activeSegmentName, STEP_KEYS[key]); - applySegments(steppedSegments); - moveCaret(activeSegmentName, caretOffset, steppedSegments); + enterSegment(getAdjacentSegmentName(activeSegmentName, MOVE_KEYS[key])); return; } - if (isMoveKey(key)) { + if (key === BACKSPACE_KEY || key === DELETE_KEY) { event.preventDefault(); - const step = MOVE_KEYS[key]; - const nextOffset = caretOffset + step; - - // Stepping past either end of what has been typed carries on into the neighbouring segment. - if (nextOffset >= 0 && nextOffset <= getFurthestOffset(activeSegmentName, segments)) { - moveCaret(activeSegmentName, nextOffset); - return; - } + const trimmedSegments = removeLastDigit(segments, activeSegmentName); - const adjacentSegmentName = getAdjacentSegmentName(activeSegmentName, step); - if (adjacentSegmentName === activeSegmentName) { + // An empty segment has nothing to delete, so the keystroke falls back to leaving it + if (!trimmedSegments) { + enterSegment(getAdjacentSegmentName(activeSegmentName, -1)); return; } - moveCaret(adjacentSegmentName, step > 0 ? 0 : getFurthestOffset(adjacentSegmentName, segments)); - return; - } - - if (key === BACKSPACE_KEY || key === DELETE_KEY) { - event.preventDefault(); - setSegments(clearSegment(segments, activeSegmentName)); - moveCaret(activeSegmentName, 0); + setSegments(trimmedSegments); + shouldOverwriteRef.current = false; + moveCaret(activeSegmentName, trimmedSegments[activeSegmentName].length, trimmedSegments); return; } - if (!isSeparatorKey(key)) { + if (!SEPARATOR_KEYS.has(key)) { + // Nothing else may reach the input, or the browser would write characters the mask cannot represent + if (key.length === 1) { + event.preventDefault(); + } return; } // A separator means the user is finished with this segment even if they only typed one digit into it event.preventDefault(); - moveCaret(getAdjacentSegmentName(activeSegmentName, 1), 0); + enterSegment(getAdjacentSegmentName(activeSegmentName, 1)); }; // Clicking into the text lands the caret anywhere, so snap it onto the digit place that was clicked @@ -185,6 +182,9 @@ export default function useDateSegmentInput({value, mask, isEnabled, onCommit}: const clickedSegmentName = getSegmentNameAtPosition(position, ranges); const clickedOffset = position - ranges[clickedSegmentName].start; + // Clicking is aiming at a digit place rather than arriving at a segment, so the next digit extends what is + // there instead of replacing it + shouldOverwriteRef.current = false; moveCaret(clickedSegmentName, clickedOffset); }; @@ -196,12 +196,15 @@ export default function useDateSegmentInput({value, mask, isEnabled, onCommit}: } applySegments(pastedSegments); - moveCaret(FIRST_SEGMENT_NAME, 0); + enterSegment(LAST_SEGMENT_NAME, pastedSegments); }; const handleFocus = () => { - setSegments(getSegmentsFromISODate(value)); - moveCaret(FIRST_SEGMENT_NAME, 0); + const seededSegments = getSegmentsFromISODate(value); + + setSegments(seededSegments); + shouldOverwriteRef.current = false; + moveCaret(FIRST_SEGMENT_NAME, getFurthestOffset(FIRST_SEGMENT_NAME, seededSegments), seededSegments); setIsEditing(true); }; @@ -210,6 +213,7 @@ export default function useDateSegmentInput({value, mask, isEnabled, onCommit}: setIsEditing(false); setSegments(EMPTY_SEGMENTS); setCaretOffset(0); + shouldOverwriteRef.current = false; }; if (!isEnabled) { diff --git a/src/libs/DateInputMaskUtils.ts b/src/libs/DateInputMaskUtils.ts index 0e60781d9673..6c765a7a92d2 100644 --- a/src/libs/DateInputMaskUtils.ts +++ b/src/libs/DateInputMaskUtils.ts @@ -7,7 +7,7 @@ import CONST from '@src/CONST'; import type {TupleToUnion} from 'type-fest'; -import {getDaysInMonth, isValid, parse} from 'date-fns'; +import {isValid, parse} from 'date-fns'; const DATE_SEGMENT_NAMES = ['year', 'month', 'day'] as const; @@ -15,17 +15,16 @@ const YEAR_LENGTH = 4; const SEGMENT_LENGTH = 2; const FIRST_MONTH = 1; -const LAST_MONTH = 12; -const FIRST_DAY = 1; -/** The longest month, used as the day limit until the typed month says otherwise */ -const MAX_DAYS_IN_MONTH = 31; - -/** A leading month digit above this cannot start a two digit month, so the segment is zero padded and completed early */ -const MAX_LEADING_MONTH_DIGIT = 1; - -/** A leading day digit above this cannot start a two digit day, so the segment is zero padded and completed early */ -const MAX_LEADING_DAY_DIGIT = 3; +/** + * The highest a segment may read, and the highest its leading digit may be while still allowing a second one. The day + * is capped at the longest month rather than the one that has been typed, so an impossible date such as the 31st of + * February can be entered and is then rejected by validation, rather than being silently corrected mid-keystroke. + */ +const SEGMENT_LIMITS = { + month: {max: 12, maxLeadingDigit: 1}, + day: {max: 31, maxLeadingDigit: 3}, +} as const; /** Mask characters standing in for a digit are letters, so anything else is a separator to copy through verbatim */ const MASK_LETTER_REGEX = /\p{L}/u; @@ -101,24 +100,6 @@ function getSegmentLength(name: DateSegmentName): number { return name === 'year' ? YEAR_LENGTH : SEGMENT_LENGTH; } -function getDaysInTypedMonth(segments: DateSegments): number { - if (segments.month.length !== SEGMENT_LENGTH || segments.year.length !== YEAR_LENGTH) { - return MAX_DAYS_IN_MONTH; - } - - return getDaysInMonth(new Date(Number(segments.year), Number(segments.month) - 1)); -} - -/** Trims a day the newly typed year or month cannot have, so February never keeps a 30th from the month before */ -function withDayInMonth(segments: DateSegments): DateSegments { - if (segments.day.length !== SEGMENT_LENGTH) { - return segments; - } - - const daysInMonth = getDaysInTypedMonth(segments); - return Number(segments.day) > daysInMonth ? {...segments, day: String(daysInMonth).padStart(SEGMENT_LENGTH, '0')} : segments; -} - function getDateDisplay(segments: DateSegments, mask: string): DateDisplay { let value = ''; const ranges: Record = {year: {start: 0, end: 0}, month: {start: 0, end: 0}, day: {start: 0, end: 0}}; @@ -143,6 +124,11 @@ function getSegmentNameAtPosition(position: number, ranges: Record= getSegmentLength(name) ? '' : segments[name]; +function typeDigitIntoOneSegment(name: DateSegmentName, typedSoFar: string, digit: string): SegmentDigitResult { + const current = typedSoFar.length >= getSegmentLength(name) ? '' : typedSoFar; if (name === 'year') { - // No year we support starts with a zero, so swallow the keystroke rather than start a year that cannot resolve - if (!current && digit === '0') { - return {segments, isSegmentComplete: false}; - } + const year = `${current}${digit}`.slice(0, YEAR_LENGTH); - const year = `${current}${digit}`; - return {segments: withDayInMonth({...segments, year}), isSegmentComplete: year.length === YEAR_LENGTH}; + return {value: year, shouldAdvance: year.length === YEAR_LENGTH}; } - const isMonth = name === 'month'; - const maxLeadingDigit = isMonth ? MAX_LEADING_MONTH_DIGIT : MAX_LEADING_DAY_DIGIT; - const lowest = isMonth ? FIRST_MONTH : FIRST_DAY; - const highest = isMonth ? LAST_MONTH : getDaysInTypedMonth(segments); + const limits = SEGMENT_LIMITS[name]; - if (!current) { - if (Number(digit) > maxLeadingDigit) { - const padded = `0${digit}`; - return {segments: withDayInMonth({...segments, [name]: padded}), isSegmentComplete: true}; + if (current.length === 1) { + const combined = `${current}${digit}`; + const combinedNumber = Number(combined); + + if (combinedNumber >= FIRST_MONTH && combinedNumber <= limits.max) { + return {value: combined, shouldAdvance: true}; } - return {segments: {...segments, [name]: digit}, isSegmentComplete: false}; + return {value: current.padStart(SEGMENT_LENGTH, '0'), shouldAdvance: true, carry: digit}; } - const candidate = Number(`${current}${digit}`); - if (candidate >= lowest && candidate <= highest) { - return {segments: withDayInMonth({...segments, [name]: `${current}${digit}`}), isSegmentComplete: true}; + // A leading digit this high cannot start a two digit number, so the segment is zero padded and finished early + if (digit !== '0' && Number(digit) > limits.maxLeadingDigit) { + return {value: digit.padStart(SEGMENT_LENGTH, '0'), shouldAdvance: true}; } - // The two digit number is out of range, so treat the keystroke as the start of a new segment instead - return typeDigitIntoSegment({...segments, [name]: ''}, name, digit); + return {value: digit, shouldAdvance: false}; } -/** Moves a segment up or down by `offset`, wrapping months and days and starting from today when the segment is empty */ -function stepSegment(segments: DateSegments, name: DateSegmentName, offset: number): DateSegments { - const today = new Date(); +/** + * Adds one typed digit, following a carried digit into the following segments for as long as they keep handing one on. + * `nextSegmentName` is where the caret belongs afterwards, and is undefined while the segment is unfinished. + */ +function typeDigitIntoSegments( + segments: DateSegments, + name: DateSegmentName, + digit: string, + shouldOverwrite = false, +): {segments: DateSegments; nextSegmentName: DateSegmentName | undefined} { + const filled = {...segments}; + let currentName = name; + let typedSoFar = shouldOverwrite ? '' : segments[name]; + let currentDigit = digit; + let nextSegmentName: DateSegmentName | undefined; + + for (;;) { + const result = typeDigitIntoOneSegment(currentName, typedSoFar, currentDigit); + filled[currentName] = result.value; + + const followingName = getFollowingSegmentName(currentName); + if (!result.shouldAdvance || !followingName) { + break; + } - if (name === 'year') { - const current = segments.year.length === YEAR_LENGTH ? Number(segments.year) : today.getFullYear(); - const year = Math.min(Math.max(current + offset, CONST.CALENDAR_PICKER.MIN_YEAR), CONST.CALENDAR_PICKER.MAX_YEAR); + nextSegmentName = followingName; + if (!result.carry) { + break; + } - return withDayInMonth({...segments, year: String(year)}); + currentName = followingName; + typedSoFar = ''; + currentDigit = result.carry; } - const isMonth = name === 'month'; - const highest = isMonth ? LAST_MONTH : getDaysInTypedMonth(segments); - const fallback = isMonth ? today.getMonth() + FIRST_MONTH : today.getDate(); - const current = segments[name].length === SEGMENT_LENGTH ? Number(segments[name]) : fallback; - const stepped = ((current - 1 + offset + highest) % highest) + 1; - - return withDayInMonth({...segments, [name]: String(stepped).padStart(SEGMENT_LENGTH, '0')}); + return {segments: filled, nextSegmentName}; } -function clearSegment(segments: DateSegments, name: DateSegmentName): DateSegments { - return {...segments, [name]: ''}; +/** Drops the last digit of a segment. Returns undefined when there was nothing left to drop */ +function removeLastDigit(segments: DateSegments, name: DateSegmentName): DateSegments | undefined { + if (!segments[name]) { + return undefined; + } + + return {...segments, [name]: segments[name].slice(0, -1)}; } function getSegmentsFromISODate(value: string | undefined): DateSegments { @@ -244,7 +259,7 @@ function getSegmentsFromText(text: string): DateSegments { break; } - filled = typeDigitIntoSegment(filled, name, digit).segments; + filled = typeDigitIntoSegments(filled, name, digit).segments; } return filled; @@ -268,7 +283,6 @@ function hasAnySegment(segments: DateSegments): boolean { export { DATE_SEGMENT_NAMES, EMPTY_SEGMENTS, - clearSegment, getAdjacentSegmentName, getDateDisplay, getISODateFromSegments, @@ -276,7 +290,7 @@ export { getSegmentsFromISODate, getSegmentsFromText, hasAnySegment, - stepSegment, - typeDigitIntoSegment, + removeLastDigit, + typeDigitIntoSegments, }; export type {DateSegmentName, DateSegmentRange, DateSegments}; diff --git a/tests/unit/DateInputMaskUtilsTest.ts b/tests/unit/DateInputMaskUtilsTest.ts index 6b113facbe87..71abd5d2aec8 100644 --- a/tests/unit/DateInputMaskUtilsTest.ts +++ b/tests/unit/DateInputMaskUtilsTest.ts @@ -1,5 +1,4 @@ import { - clearSegment, getAdjacentSegmentName, getDateDisplay, getISODateFromSegments, @@ -7,8 +6,8 @@ import { getSegmentsFromISODate, getSegmentsFromText, hasAnySegment, - stepSegment, - typeDigitIntoSegment, + removeLastDigit, + typeDigitIntoSegments, } from '@libs/DateInputMaskUtils'; import type {DateSegments} from '@libs/DateInputMaskUtils'; @@ -20,75 +19,65 @@ function segments(year: string, month: string, day: string): DateSegments { } describe('DateInputMaskUtils', () => { - describe('typeDigitIntoSegment', () => { - it('rejects a leading zero in the year', () => { - expect(typeDigitIntoSegment(EMPTY, 'year', '0')).toEqual({segments: EMPTY, isSegmentComplete: false}); + describe('typeDigitIntoSegments', () => { + it('completes the year on the fourth digit', () => { + expect(typeDigitIntoSegments(segments('202', '', ''), 'year', '6')).toEqual({segments: segments('2026', '', ''), nextSegmentName: 'month'}); + expect(typeDigitIntoSegments(segments('20', '', ''), 'year', '2')).toEqual({segments: segments('202', '', ''), nextSegmentName: undefined}); }); - it('completes the year on the fourth digit', () => { - expect(typeDigitIntoSegment(segments('202', '', ''), 'year', '6')).toEqual({segments: segments('2026', '', ''), isSegmentComplete: true}); - expect(typeDigitIntoSegment(segments('20', '', ''), 'year', '2')).toEqual({segments: segments('202', '', ''), isSegmentComplete: false}); + it('takes a leading zero in the year, which only validation can reject', () => { + expect(typeDigitIntoSegments(EMPTY, 'year', '0')).toEqual({segments: segments('0', '', ''), nextSegmentName: undefined}); }); it('starts the year over when it is already full', () => { - expect(typeDigitIntoSegment(segments('2026', '', ''), 'year', '1')).toEqual({segments: segments('1', '', ''), isSegmentComplete: false}); + expect(typeDigitIntoSegments(segments('2026', '', ''), 'year', '1')).toEqual({segments: segments('1', '', ''), nextSegmentName: undefined}); + }); + + it('replaces the segment rather than extending it when told to overwrite', () => { + expect(typeDigitIntoSegments(segments('202', '', ''), 'year', '9', true)).toEqual({segments: segments('9', '', ''), nextSegmentName: undefined}); }); it('zero pads a month that cannot start a two digit month', () => { - expect(typeDigitIntoSegment(segments('2026', '', ''), 'month', '9')).toEqual({segments: segments('2026', '09', ''), isSegmentComplete: true}); + expect(typeDigitIntoSegments(segments('2026', '', ''), 'month', '9')).toEqual({segments: segments('2026', '09', ''), nextSegmentName: 'day'}); }); it('waits for a second digit when the month could still be a teen month', () => { - expect(typeDigitIntoSegment(segments('2026', '', ''), 'month', '1')).toEqual({segments: segments('2026', '1', ''), isSegmentComplete: false}); - expect(typeDigitIntoSegment(segments('2026', '1', ''), 'month', '2')).toEqual({segments: segments('2026', '12', ''), isSegmentComplete: true}); + expect(typeDigitIntoSegments(segments('2026', '', ''), 'month', '1')).toEqual({segments: segments('2026', '1', ''), nextSegmentName: undefined}); + expect(typeDigitIntoSegments(segments('2026', '1', ''), 'month', '2')).toEqual({segments: segments('2026', '12', ''), nextSegmentName: 'day'}); }); - it('restarts the month when the two digit number is out of range', () => { - expect(typeDigitIntoSegment(segments('2026', '1', ''), 'month', '3')).toEqual({segments: segments('2026', '03', ''), isSegmentComplete: true}); - expect(typeDigitIntoSegment(segments('2026', '0', ''), 'month', '0')).toEqual({segments: segments('2026', '0', ''), isSegmentComplete: false}); + it('carries a digit the month cannot take into the day', () => { + expect(typeDigitIntoSegments(segments('2026', '1', ''), 'month', '3')).toEqual({segments: segments('2026', '01', '3'), nextSegmentName: 'day'}); }); - it('limits the day to the typed month', () => { - expect(typeDigitIntoSegment(segments('2026', '01', '3'), 'day', '1')).toEqual({segments: segments('2026', '01', '31'), isSegmentComplete: true}); - - // February has no 31st, so the keystroke starts the day over instead - expect(typeDigitIntoSegment(segments('2026', '02', '3'), 'day', '1')).toEqual({segments: segments('2026', '02', '1'), isSegmentComplete: false}); + it('waits on a zero rather than padding it, since 0 is not a month', () => { + expect(typeDigitIntoSegments(segments('2026', '0', ''), 'month', '0')).toEqual({segments: segments('2026', '0', ''), nextSegmentName: undefined}); }); - it('allows February 29 in a leap year only', () => { - expect(typeDigitIntoSegment(segments('2024', '02', '2'), 'day', '9')).toEqual({segments: segments('2024', '02', '29'), isSegmentComplete: true}); - expect(typeDigitIntoSegment(segments('2026', '02', '2'), 'day', '9')).toEqual({segments: segments('2026', '02', '09'), isSegmentComplete: true}); - }); + it('caps the day at the longest month rather than the one that was typed', () => { + expect(typeDigitIntoSegments(segments('2026', '01', '3'), 'day', '1')).toEqual({segments: segments('2026', '01', '31'), nextSegmentName: undefined}); - it('allows any day up to 31 before the month is known', () => { - expect(typeDigitIntoSegment(segments('', '', '3'), 'day', '1')).toEqual({segments: segments('', '', '31'), isSegmentComplete: true}); + // February has no 31st, but the field takes it and validation is what rejects the date + expect(typeDigitIntoSegments(segments('2026', '02', '3'), 'day', '1')).toEqual({segments: segments('2026', '02', '31'), nextSegmentName: undefined}); }); - it('trims a day the newly typed month cannot have', () => { - expect(typeDigitIntoSegment(segments('2026', '1', '31'), 'month', '1').segments).toEqual(segments('2026', '11', '30')); + it('keeps a day the newly typed month cannot have, leaving it to validation', () => { + expect(typeDigitIntoSegments(segments('2026', '1', '31'), 'month', '1').segments).toEqual(segments('2026', '11', '31')); }); - it('trims a day the newly typed year cannot have', () => { - expect(typeDigitIntoSegment(segments('202', '02', '29'), 'year', '6').segments).toEqual(segments('2026', '02', '28')); + it('has nothing to carry into past the day, so a rejected pair restarts it', () => { + expect(typeDigitIntoSegments(segments('2026', '09', '3'), 'day', '9')).toEqual({segments: segments('2026', '09', '03'), nextSegmentName: undefined}); }); }); - describe('stepSegment', () => { - it('wraps the month at both ends', () => { - expect(stepSegment(segments('2026', '12', ''), 'month', 1)).toEqual(segments('2026', '01', '')); - expect(stepSegment(segments('2026', '01', ''), 'month', -1)).toEqual(segments('2026', '12', '')); + describe('removeLastDigit', () => { + it('drops one digit at a time', () => { + expect(removeLastDigit(segments('2026', '', ''), 'year')).toEqual(segments('202', '', '')); + expect(removeLastDigit(segments('2', '', ''), 'year')).toEqual(EMPTY); }); - it('wraps the day within the typed month', () => { - expect(stepSegment(segments('2026', '02', '28'), 'day', 1)).toEqual(segments('2026', '02', '01')); - }); - - it('steps the year without wrapping', () => { - expect(stepSegment(segments('2026', '', ''), 'year', 1)).toEqual(segments('2027', '', '')); - }); - - it('trims the day when stepping into a shorter month', () => { - expect(stepSegment(segments('2026', '01', '31'), 'month', 1)).toEqual(segments('2026', '02', '28')); + it('reports that an empty segment had nothing to drop', () => { + expect(removeLastDigit(EMPTY, 'year')).toBeUndefined(); }); }); @@ -196,12 +185,6 @@ describe('DateInputMaskUtils', () => { }); }); - describe('clearSegment', () => { - it('empties only the named segment', () => { - expect(clearSegment(segments('2026', '09', '18'), 'month')).toEqual(segments('2026', '', '18')); - }); - }); - describe('hasAnySegment', () => { it('reports whether anything has been filled in', () => { expect(hasAnySegment(EMPTY)).toBe(false); From 0d46d4fcfda32c9acface73cf29ea98c47d63086 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 17 Sep 2026 22:01:08 +0530 Subject: [PATCH 05/23] fix: do not drag the calendar to the date limit while a year is typed Signed-off-by: krishna2323 --- .../DatePicker/CalendarPicker/index.tsx | 16 ++++++++ src/components/DatePicker/DatePickerModal.tsx | 2 + src/components/DatePicker/index.tsx | 3 +- src/components/DatePicker/types.ts | 3 ++ src/hooks/useDateSegmentInput.ts | 30 ++++++++++++++- src/libs/DateInputMaskUtils.ts | 25 +++++++++++- tests/unit/DateInputMaskUtilsTest.ts | 38 +++++++++++++++++++ 7 files changed, 113 insertions(+), 4 deletions(-) diff --git a/src/components/DatePicker/CalendarPicker/index.tsx b/src/components/DatePicker/CalendarPicker/index.tsx index bd70d3ca7094..73aa26e33b5a 100644 --- a/src/components/DatePicker/CalendarPicker/index.tsx +++ b/src/components/DatePicker/CalendarPicker/index.tsx @@ -52,6 +52,12 @@ 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; }; function getInitialCurrentDateView(value: Date | string, minDate: Date, maxDate: Date) { @@ -85,6 +91,7 @@ function CalendarPicker({ headerContainerStyle, containerStyle, shouldEnableMonthYearBackdropInNarrowPane = false, + viewDate, }: CalendarPickerProps) { // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth} = useResponsiveLayout(); @@ -94,10 +101,19 @@ function CalendarPicker({ const pressableRef = useRef(null); const monthPressableRef = useRef(null); const [currentDateView, setCurrentDateView] = useState(() => getInitialCurrentDateView(value, minDate, maxDate)); + const [appliedViewDate, setAppliedViewDate] = useState(viewDate); 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 && viewDate.getTime() !== appliedViewDate?.getTime()) { + setAppliedViewDate(viewDate); + setCurrentDateView(viewDate); + } + const currentMonthView = currentDateView.getMonth(); const currentYearView = currentDateView.getFullYear(); const calendarDaysMatrix = generateMonthMatrix(currentYearView, currentMonthView); diff --git a/src/components/DatePicker/DatePickerModal.tsx b/src/components/DatePicker/DatePickerModal.tsx index 0b0f76f4de9b..8df9742fc161 100644 --- a/src/components/DatePicker/DatePickerModal.tsx +++ b/src/components/DatePicker/DatePickerModal.tsx @@ -49,6 +49,7 @@ function DatePickerModal({ anchorRef: anchorRefProp, withoutOverlay = false, shouldAllowWithoutOverlayInNarrowPane = false, + viewDate, }: DatePickerProps) { const [selectedDate, setSelectedDate] = useState(value ?? defaultValue ?? undefined); const fallbackAnchorRef = useRef(null); @@ -111,6 +112,7 @@ function DatePickerModal({ onSelected={handleDateSelection} containerStyle={bottomSafeAreaPaddingStyle} shouldEnableMonthYearBackdropInNarrowPane={shouldEnableMonthYearBackdropInNarrowPane} + viewDate={viewDate} /> ); diff --git a/src/components/DatePicker/index.tsx b/src/components/DatePicker/index.tsx index e4c91cbaf511..a0c02384a5fe 100644 --- a/src/components/DatePicker/index.tsx +++ b/src/components/DatePicker/index.tsx @@ -80,7 +80,7 @@ function DatePicker({ onInputChange?.(newDate); }; - const segmentInput = useDateSegmentInput({value: selectedDate, mask: dateMask, isEnabled: shouldAllowTyping, onCommit: handleTypedDate}); + const segmentInput = useDateSegmentInput({value: selectedDate, mask: dateMask, isEnabled: shouldAllowTyping, minDate, maxDate, onCommit: handleTypedDate}); const {inputCallbackRef: autoFocusCallbackRef, cancelAutoFocus} = useAutoFocusInput(); const autoFocusCallbackRefRef = useRef(autoFocusCallbackRef); @@ -313,6 +313,7 @@ function DatePicker({ anchorRef={anchorRef} withoutOverlay={shouldAllowTyping} shouldAllowWithoutOverlayInNarrowPane={shouldAllowTyping} + viewDate={segmentInput.viewDate} /> ); diff --git a/src/components/DatePicker/types.ts b/src/components/DatePicker/types.ts index 32c214cf8de8..1fb1dec3ac40 100644 --- a/src/components/DatePicker/types.ts +++ b/src/components/DatePicker/types.ts @@ -144,6 +144,9 @@ type DatePickerProps = { * calendar, so the user can keep editing the input while the calendar is open. Falls back to an internal ref. */ anchorRef?: RefObject; + + /** Moves the calendar to this month without selecting a day, so it follows the date being typed into the input */ + viewDate?: Date; } & Omit; export type {DateInputWithPickerProps, DatePickerProps}; diff --git a/src/hooks/useDateSegmentInput.ts b/src/hooks/useDateSegmentInput.ts index 958cbc8106a1..ad5195b1416b 100644 --- a/src/hooks/useDateSegmentInput.ts +++ b/src/hooks/useDateSegmentInput.ts @@ -11,6 +11,7 @@ import { getSegmentNameAtPosition, getSegmentsFromISODate, getSegmentsFromText, + getViewDateFromSegments, hasAnySegment, removeLastDigit, typeDigitIntoSegments, @@ -42,6 +43,12 @@ type UseDateSegmentInputParams = { /** Whether this platform lets the user type a date at all */ isEnabled: boolean; + /** The oldest date the calendar can show, so it is not moved somewhere with nothing to select */ + minDate: Date; + + /** The newest date the calendar can show */ + maxDate: Date; + /** Called with a stored format date once every segment holds a valid value */ onCommit: (isoDate: string) => void; }; @@ -53,6 +60,9 @@ type UseDateSegmentInputResult = { /** The range covering the segment being edited, which selects it as a whole */ selection: DateSegmentRange | undefined; + /** The month the calendar should show, so it follows the date being typed. Undefined leaves the calendar alone */ + viewDate: Date | undefined; + onKeyPress: (event: TextInputKeyPressEvent) => void; onSelectionChange: (event: TextInputSelectionChangeEvent) => void; onChangeText: (text: string) => void; @@ -64,12 +74,14 @@ function isMoveKey(key: string): key is keyof typeof MOVE_KEYS { return key in MOVE_KEYS; } -export default function useDateSegmentInput({value, mask, isEnabled, onCommit}: UseDateSegmentInputParams): UseDateSegmentInputResult { +export default function useDateSegmentInput({value, mask, isEnabled, minDate, maxDate, onCommit}: UseDateSegmentInputParams): UseDateSegmentInputResult { // The segments only describe an edit in progress, so they are seeded on focus rather than synced with the value const [segments, setSegments] = useState(EMPTY_SEGMENTS); const [activeSegmentName, setActiveSegmentName] = useState(FIRST_SEGMENT_NAME); const [caretOffset, setCaretOffset] = useState(0); const [isEditing, setIsEditing] = useState(false); + // The month the calendar should show, which follows the typed date once the year is complete + const [viewDate, setViewDate] = useState(undefined); // Re-rendering with a new value makes the browser report a caret of its own choosing. Honouring that would drag // the active segment around, so the first report after a keystroke is discarded as an echo of our own update. const hasPendingCaretEchoRef = useRef(false); @@ -113,8 +125,18 @@ export default function useDateSegmentInput({value, mask, isEnabled, onCommit}: onCommit(isoDate); }; + /** + * The one place segments are written, so the calendar cannot fall out of step with them. The month already on + * screen is the fallback while the typed month is unfinished, which keeps the calendar where the user left it. + */ const applySegments = (newSegments: DateSegments) => { setSegments(newSegments); + + const nextViewDate = getViewDateFromSegments(newSegments, (viewDate ?? new Date()).getMonth(), minDate, maxDate); + if (nextViewDate) { + setViewDate(nextViewDate); + } + commitIfComplete(newSegments); }; @@ -152,7 +174,7 @@ export default function useDateSegmentInput({value, mask, isEnabled, onCommit}: return; } - setSegments(trimmedSegments); + applySegments(trimmedSegments); shouldOverwriteRef.current = false; moveCaret(activeSegmentName, trimmedSegments[activeSegmentName].length, trimmedSegments); return; @@ -203,6 +225,7 @@ export default function useDateSegmentInput({value, mask, isEnabled, onCommit}: const seededSegments = getSegmentsFromISODate(value); setSegments(seededSegments); + setViewDate(getViewDateFromSegments(seededSegments, new Date().getMonth(), minDate, maxDate)); shouldOverwriteRef.current = false; moveCaret(FIRST_SEGMENT_NAME, getFurthestOffset(FIRST_SEGMENT_NAME, seededSegments), seededSegments); setIsEditing(true); @@ -213,6 +236,7 @@ export default function useDateSegmentInput({value, mask, isEnabled, onCommit}: setIsEditing(false); setSegments(EMPTY_SEGMENTS); setCaretOffset(0); + setViewDate(undefined); shouldOverwriteRef.current = false; }; @@ -220,6 +244,7 @@ export default function useDateSegmentInput({value, mask, isEnabled, onCommit}: return { displayValue: value, selection: undefined, + viewDate: undefined, onKeyPress: () => {}, onSelectionChange: () => {}, onChangeText: () => {}, @@ -231,6 +256,7 @@ export default function useDateSegmentInput({value, mask, isEnabled, onCommit}: return { displayValue: isEditing ? editingValue : value, selection: isEditing ? {start: caretPosition, end: caretPosition} : undefined, + viewDate: isEditing ? viewDate : undefined, onKeyPress: handleKeyPress, onSelectionChange: handleSelectionChange, onChangeText: handleChangeText, diff --git a/src/libs/DateInputMaskUtils.ts b/src/libs/DateInputMaskUtils.ts index 6c765a7a92d2..34cc7d89dec9 100644 --- a/src/libs/DateInputMaskUtils.ts +++ b/src/libs/DateInputMaskUtils.ts @@ -7,7 +7,7 @@ import CONST from '@src/CONST'; import type {TupleToUnion} from 'type-fest'; -import {isValid, parse} from 'date-fns'; +import {endOfMonth, isValid, parse} from 'date-fns'; const DATE_SEGMENT_NAMES = ['year', 'month', 'day'] as const; @@ -221,6 +221,28 @@ function typeDigitIntoSegments( return {segments: filled, nextSegmentName}; } +/** + * Which month the calendar should show for the date being typed, or undefined when there is nowhere useful to go and + * the calendar should stay where it is. A month only counts once both its digits read as a real month, so the calendar + * does not lurch to January while the user is still on the first digit. + * + * A month outside the allowed range is refused rather than clamped. Every year is out of range while it is being + * typed, since 1985 passes through 1, 19 and 198, and clamping those would drag the calendar to the limit and back on + * every keystroke. + */ +function getViewDateFromSegments(segments: DateSegments, fallbackMonthIndex: number, minDate: Date, maxDate: Date): Date | undefined { + if (segments.year.length !== YEAR_LENGTH) { + return undefined; + } + + const monthNumber = Number(segments.month); + const hasMonth = segments.month.length === SEGMENT_LENGTH && monthNumber >= FIRST_MONTH && monthNumber <= SEGMENT_LIMITS.month.max; + const viewDate = new Date(Number(segments.year), hasMonth ? monthNumber - 1 : fallbackMonthIndex, 1); + + // A month holding no selectable day at all is not worth moving to + return endOfMonth(viewDate) < minDate || viewDate > maxDate ? undefined : viewDate; +} + /** Drops the last digit of a segment. Returns undefined when there was nothing left to drop */ function removeLastDigit(segments: DateSegments, name: DateSegmentName): DateSegments | undefined { if (!segments[name]) { @@ -289,6 +311,7 @@ export { getSegmentNameAtPosition, getSegmentsFromISODate, getSegmentsFromText, + getViewDateFromSegments, hasAnySegment, removeLastDigit, typeDigitIntoSegments, diff --git a/tests/unit/DateInputMaskUtilsTest.ts b/tests/unit/DateInputMaskUtilsTest.ts index 71abd5d2aec8..2090447abba2 100644 --- a/tests/unit/DateInputMaskUtilsTest.ts +++ b/tests/unit/DateInputMaskUtilsTest.ts @@ -5,6 +5,7 @@ import { getSegmentNameAtPosition, getSegmentsFromISODate, getSegmentsFromText, + getViewDateFromSegments, hasAnySegment, removeLastDigit, typeDigitIntoSegments, @@ -70,6 +71,43 @@ describe('DateInputMaskUtils', () => { }); }); + describe('getViewDateFromSegments', () => { + /** September, standing in for the month the calendar happens to be showing */ + const FALLBACK_MONTH_INDEX = 8; + const MIN_DATE = new Date(1876, 8, 17); + const MAX_DATE = new Date(2126, 8, 17); + const viewDateFor = (dateSegments: DateSegments) => getViewDateFromSegments(dateSegments, FALLBACK_MONTH_INDEX, MIN_DATE, MAX_DATE); + + it('leaves the calendar alone while the year is unfinished', () => { + expect(viewDateFor(segments('202', '', ''))).toBeUndefined(); + expect(viewDateFor(EMPTY)).toBeUndefined(); + }); + + it('moves the year while keeping the month on screen', () => { + expect(viewDateFor(segments('2030', '', ''))).toEqual(new Date(2030, 8, 1)); + expect(viewDateFor(segments('2030', '1', ''))).toEqual(new Date(2030, 8, 1)); + }); + + it('moves the month once both of its digits read as a real month', () => { + expect(viewDateFor(segments('2030', '02', ''))).toEqual(new Date(2030, 1, 1)); + expect(viewDateFor(segments('2030', '00', ''))).toEqual(new Date(2030, 8, 1)); + }); + + it('ignores the day, which picks a date rather than a month to show', () => { + expect(viewDateFor(segments('2030', '02', '28'))).toEqual(new Date(2030, 1, 1)); + }); + + it('stays put for a year outside the range rather than jumping to the limit', () => { + expect(viewDateFor(segments('1111', '01', '03'))).toBeUndefined(); + expect(viewDateFor(segments('9999', '01', ''))).toBeUndefined(); + }); + + it('moves to the month holding the limit itself, which still has days to select', () => { + expect(viewDateFor(segments('1876', '09', ''))).toEqual(new Date(1876, 8, 1)); + expect(viewDateFor(segments('1876', '08', ''))).toBeUndefined(); + }); + }); + describe('removeLastDigit', () => { it('drops one digit at a time', () => { expect(removeLastDigit(segments('2026', '', ''), 'year')).toEqual(segments('202', '', '')); From b13855fd075fca9f3d3946fffedeaae3bd37f25e Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 17 Sep 2026 22:05:32 +0530 Subject: [PATCH 06/23] fix: fill the month and day from the right so a single digit shows as 02 Signed-off-by: krishna2323 --- src/hooks/useDateSegmentInput.ts | 9 ++++---- src/libs/DateInputMaskUtils.ts | 31 +++++++++++++++++++++++++--- tests/unit/DateInputMaskUtilsTest.ts | 27 ++++++++++++++++++++++-- 3 files changed, 58 insertions(+), 9 deletions(-) diff --git a/src/hooks/useDateSegmentInput.ts b/src/hooks/useDateSegmentInput.ts index ad5195b1416b..824b8515b2b6 100644 --- a/src/hooks/useDateSegmentInput.ts +++ b/src/hooks/useDateSegmentInput.ts @@ -6,6 +6,7 @@ import { DATE_SEGMENT_NAMES, EMPTY_SEGMENTS, getAdjacentSegmentName, + getCaretOffsetLimit, getDateDisplay, getISODateFromSegments, getSegmentNameAtPosition, @@ -98,7 +99,7 @@ export default function useDateSegmentInput({value, mask, isEnabled, minDate, ma * A caret may rest on any digit place already typed, or just after the last of them, but never out on a mask * letter. An empty segment therefore only ever has its start, which is what stops a click landing on a bare Y. */ - const getFurthestOffset = (name: DateSegmentName, currentSegments: DateSegments) => currentSegments[name].length; + const getFurthestOffset = (name: DateSegmentName, currentSegments: DateSegments) => getCaretOffsetLimit(currentSegments, name); const moveCaret = (name: DateSegmentName, offset: number, nextSegments: DateSegments = segments) => { hasPendingCaretEchoRef.current = true; @@ -154,7 +155,7 @@ export default function useDateSegmentInput({value, mask, isEnabled, minDate, ma return; } - moveCaret(activeSegmentName, result.segments[activeSegmentName].length, result.segments); + moveCaret(activeSegmentName, getFurthestOffset(activeSegmentName, result.segments), result.segments); return; } @@ -176,7 +177,7 @@ export default function useDateSegmentInput({value, mask, isEnabled, minDate, ma applySegments(trimmedSegments); shouldOverwriteRef.current = false; - moveCaret(activeSegmentName, trimmedSegments[activeSegmentName].length, trimmedSegments); + moveCaret(activeSegmentName, getFurthestOffset(activeSegmentName, trimmedSegments), trimmedSegments); return; } @@ -227,7 +228,7 @@ export default function useDateSegmentInput({value, mask, isEnabled, minDate, ma setSegments(seededSegments); setViewDate(getViewDateFromSegments(seededSegments, new Date().getMonth(), minDate, maxDate)); shouldOverwriteRef.current = false; - moveCaret(FIRST_SEGMENT_NAME, getFurthestOffset(FIRST_SEGMENT_NAME, seededSegments), seededSegments); + moveCaret(FIRST_SEGMENT_NAME, getCaretOffsetLimit(seededSegments, FIRST_SEGMENT_NAME), seededSegments); setIsEditing(true); }; diff --git a/src/libs/DateInputMaskUtils.ts b/src/libs/DateInputMaskUtils.ts index 34cc7d89dec9..810b3c0836c1 100644 --- a/src/libs/DateInputMaskUtils.ts +++ b/src/libs/DateInputMaskUtils.ts @@ -100,15 +100,39 @@ function getSegmentLength(name: DateSegmentName): number { return name === 'year' ? YEAR_LENGTH : SEGMENT_LENGTH; } +/** + * Whether a segment reads as a zero padded number, which the two digit ones do and the year does not. Their digits + * therefore sit at the end of the segment, so a single typed digit shows as 02 rather than 2 followed by a mask letter. + */ +function isZeroPaddedSegment(name: DateSegmentName): name is keyof typeof SEGMENT_LIMITS { + return name in SEGMENT_LIMITS; +} + +/** + * How far into a segment the caret may sit. A zero padded segment displays its digits at the end, so one typed digit + * puts the caret at the end of the segment rather than one place into it. + */ +function getCaretOffsetLimit(segments: DateSegments, name: DateSegmentName): number { + const typedLength = segments[name].length; + + if (!typedLength) { + return 0; + } + + return isZeroPaddedSegment(name) ? getSegmentLength(name) : Math.min(typedLength, getSegmentLength(name)); +} + function getDateDisplay(segments: DateSegments, mask: string): DateDisplay { let value = ''; const ranges: Record = {year: {start: 0, end: 0}, month: {start: 0, end: 0}, day: {start: 0, end: 0}}; for (const part of getDateMaskParts(mask)) { - // Typed digits replace the mask letters one at a time, so a half typed year reads as 2YYY rather than 2. This - // keeps every segment the width of its mask, which is what lets a caret position mean the same thing twice. + // Typed digits take the place of the mask letters, so a half typed year reads as 2YYY rather than 2. A zero + // padded segment fills from the right instead, so a day part way through reads as 02 and becomes 23 on the + // next digit. Either way the segment keeps the width of its mask, which is what lets a caret position mean + // the same thing twice. const digits = segments[part.name].slice(0, part.placeholder.length); - const text = `${digits}${part.placeholder.slice(digits.length)}`; + const text = digits && isZeroPaddedSegment(part.name) ? digits.padStart(part.placeholder.length, '0') : `${digits}${part.placeholder.slice(digits.length)}`; ranges[part.name] = {start: value.length, end: value.length + text.length}; value += `${text}${part.separator}`; @@ -306,6 +330,7 @@ export { DATE_SEGMENT_NAMES, EMPTY_SEGMENTS, getAdjacentSegmentName, + getCaretOffsetLimit, getDateDisplay, getISODateFromSegments, getSegmentNameAtPosition, diff --git a/tests/unit/DateInputMaskUtilsTest.ts b/tests/unit/DateInputMaskUtilsTest.ts index 2090447abba2..67554c9de5b4 100644 --- a/tests/unit/DateInputMaskUtilsTest.ts +++ b/tests/unit/DateInputMaskUtilsTest.ts @@ -1,5 +1,6 @@ import { getAdjacentSegmentName, + getCaretOffsetLimit, getDateDisplay, getISODateFromSegments, getSegmentNameAtPosition, @@ -130,10 +131,14 @@ describe('DateInputMaskUtils', () => { expect(getDateDisplay(segments('2026', '09', '18'), MASK).value).toBe('2026-09-18'); }); - it('keeps the mask letters of the digit places a half typed segment has not reached', () => { + it('keeps the mask letters of the digit places a half typed year has not reached', () => { expect(getDateDisplay(segments('2', '', ''), MASK).value).toBe('2YYY-MM-DD'); expect(getDateDisplay(segments('20', '', ''), MASK).value).toBe('20YY-MM-DD'); - expect(getDateDisplay(segments('2026', '1', ''), MASK).value).toBe('2026-1M-DD'); + }); + + it('zero pads a half typed month or day, which fill from the right', () => { + expect(getDateDisplay(segments('2026', '1', ''), MASK).value).toBe('2026-01-DD'); + expect(getDateDisplay(segments('2026', '09', '2'), MASK).value).toBe('2026-09-02'); }); it('reports where each segment sits in the text', () => { @@ -156,6 +161,24 @@ describe('DateInputMaskUtils', () => { }); }); + describe('getCaretOffsetLimit', () => { + it('keeps the caret at the start of an empty segment', () => { + expect(getCaretOffsetLimit(EMPTY, 'year')).toBe(0); + expect(getCaretOffsetLimit(EMPTY, 'day')).toBe(0); + }); + + it('follows the typed digits through the year, which fills from the left', () => { + expect(getCaretOffsetLimit(segments('2', '', ''), 'year')).toBe(1); + expect(getCaretOffsetLimit(segments('202', '', ''), 'year')).toBe(3); + expect(getCaretOffsetLimit(segments('2026', '', ''), 'year')).toBe(4); + }); + + it('rests at the end of a zero padded segment, since 02 shows the 2 last', () => { + expect(getCaretOffsetLimit(segments('2026', '1', ''), 'month')).toBe(2); + expect(getCaretOffsetLimit(segments('2026', '12', ''), 'month')).toBe(2); + }); + }); + describe('getSegmentNameAtPosition', () => { const {ranges} = getDateDisplay(segments('2026', '09', '18'), MASK); From b9896d653f0dbe41acee419abd577682d269d736 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 17 Sep 2026 22:11:02 +0530 Subject: [PATCH 07/23] fix: send a click on the field's empty space to the first unfilled segment Signed-off-by: krishna2323 --- src/hooks/useDateSegmentInput.ts | 13 +++++++++++-- src/libs/DateInputMaskUtils.ts | 6 ++++-- tests/unit/DateInputMaskUtilsTest.ts | 21 +++++++++++++++++++++ 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src/hooks/useDateSegmentInput.ts b/src/hooks/useDateSegmentInput.ts index 824b8515b2b6..026691ec9ce9 100644 --- a/src/hooks/useDateSegmentInput.ts +++ b/src/hooks/useDateSegmentInput.ts @@ -8,6 +8,7 @@ import { getAdjacentSegmentName, getCaretOffsetLimit, getDateDisplay, + getFirstUnfilledSegmentName, getISODateFromSegments, getSegmentNameAtPosition, getSegmentsFromISODate, @@ -202,7 +203,10 @@ export default function useDateSegmentInput({value, mask, isEnabled, minDate, ma } const position = event.nativeEvent.selection.start; - const clickedSegmentName = getSegmentNameAtPosition(position, ranges); + + // Landing past the end of the text means the empty space in the field was clicked rather than a segment, so + // the first segment still to be filled in takes it, which is the year on an untouched field. + const clickedSegmentName = position >= editingValue.length ? (getFirstUnfilledSegmentName(segments) ?? LAST_SEGMENT_NAME) : getSegmentNameAtPosition(position, ranges); const clickedOffset = position - ranges[clickedSegmentName].start; // Clicking is aiming at a digit place rather than arriving at a segment, so the next digit extends what is @@ -224,11 +228,16 @@ export default function useDateSegmentInput({value, mask, isEnabled, minDate, ma const handleFocus = () => { const seededSegments = getSegmentsFromISODate(value); + const firstSegmentName = getFirstUnfilledSegmentName(seededSegments) ?? FIRST_SEGMENT_NAME; setSegments(seededSegments); setViewDate(getViewDateFromSegments(seededSegments, new Date().getMonth(), minDate, maxDate)); shouldOverwriteRef.current = false; - moveCaret(FIRST_SEGMENT_NAME, getCaretOffsetLimit(seededSegments, FIRST_SEGMENT_NAME), seededSegments); + setActiveSegmentName(firstSegmentName); + setCaretOffset(getCaretOffsetLimit(seededSegments, firstSegmentName)); + + // Deliberately not arming the caret echo guard. The click that brought focus here reports its own position + // next, and that report is what moves the caret off the end of the text and onto a segment. setIsEditing(true); }; diff --git a/src/libs/DateInputMaskUtils.ts b/src/libs/DateInputMaskUtils.ts index 810b3c0836c1..78e68395617e 100644 --- a/src/libs/DateInputMaskUtils.ts +++ b/src/libs/DateInputMaskUtils.ts @@ -290,7 +290,8 @@ function getSegmentsFromISODate(value: string | undefined): DateSegments { }; } -function getNextUnfilledSegmentName(segments: DateSegments): DateSegmentName | undefined { +/** The first segment still short of its digits, which is where a click on the field rather than on a segment lands */ +function getFirstUnfilledSegmentName(segments: DateSegments): DateSegmentName | undefined { return DATE_SEGMENT_NAMES.find((name) => segments[name].length < getSegmentLength(name)); } @@ -300,7 +301,7 @@ function getSegmentsFromText(text: string): DateSegments { let filled = EMPTY_SEGMENTS; for (const digit of digits) { - const name = getNextUnfilledSegmentName(filled); + const name = getFirstUnfilledSegmentName(filled); if (!name) { break; } @@ -332,6 +333,7 @@ export { getAdjacentSegmentName, getCaretOffsetLimit, getDateDisplay, + getFirstUnfilledSegmentName, getISODateFromSegments, getSegmentNameAtPosition, getSegmentsFromISODate, diff --git a/tests/unit/DateInputMaskUtilsTest.ts b/tests/unit/DateInputMaskUtilsTest.ts index 67554c9de5b4..8c22ddc94840 100644 --- a/tests/unit/DateInputMaskUtilsTest.ts +++ b/tests/unit/DateInputMaskUtilsTest.ts @@ -2,6 +2,7 @@ import { getAdjacentSegmentName, getCaretOffsetLimit, getDateDisplay, + getFirstUnfilledSegmentName, getISODateFromSegments, getSegmentNameAtPosition, getSegmentsFromISODate, @@ -206,6 +207,26 @@ describe('DateInputMaskUtils', () => { }); }); + describe('getFirstUnfilledSegmentName', () => { + it('points at the year on an untouched date', () => { + expect(getFirstUnfilledSegmentName(EMPTY)).toBe('year'); + }); + + it('skips the segments already holding all of their digits', () => { + expect(getFirstUnfilledSegmentName(segments('2026', '', ''))).toBe('month'); + expect(getFirstUnfilledSegmentName(segments('2026', '09', ''))).toBe('day'); + }); + + it('counts a half typed segment as unfilled', () => { + expect(getFirstUnfilledSegmentName(segments('202', '09', '18'))).toBe('year'); + expect(getFirstUnfilledSegmentName(segments('2026', '1', ''))).toBe('month'); + }); + + it('reports nothing once the whole date is filled in', () => { + expect(getFirstUnfilledSegmentName(segments('2026', '09', '18'))).toBeUndefined(); + }); + }); + describe('getSegmentsFromText', () => { it('fills the segments from a pasted date', () => { expect(getSegmentsFromText('2026-09-18')).toEqual(segments('2026', '09', '18')); From 905605a30f51e77fd7c92eb6b7097bca63860896 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 17 Sep 2026 22:23:20 +0530 Subject: [PATCH 08/23] fix: hide the calendar icon once a digit is typed Signed-off-by: krishna2323 --- src/components/DatePicker/index.tsx | 2 +- src/hooks/useDateSegmentInput.ts | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/components/DatePicker/index.tsx b/src/components/DatePicker/index.tsx index a0c02384a5fe..c98c5842f4f3 100644 --- a/src/components/DatePicker/index.tsx +++ b/src/components/DatePicker/index.tsx @@ -268,7 +268,7 @@ function DatePicker({ ref={combinedTextInputRef} inputID={inputID} forceActiveLabel - icon={selectedDate || shouldHideCalendarIcon ? null : icons.Calendar} + icon={selectedDate || segmentInput.hasTypedDigits || shouldHideCalendarIcon ? null : icons.Calendar} iconContainerStyle={styles.pr0} label={label} accessibilityLabel={label} diff --git a/src/hooks/useDateSegmentInput.ts b/src/hooks/useDateSegmentInput.ts index 026691ec9ce9..e55b6f190993 100644 --- a/src/hooks/useDateSegmentInput.ts +++ b/src/hooks/useDateSegmentInput.ts @@ -65,6 +65,9 @@ type UseDateSegmentInputResult = { /** The month the calendar should show, so it follows the date being typed. Undefined leaves the calendar alone */ viewDate: Date | undefined; + /** Whether any digit has been typed, so the field is showing more than an untouched mask */ + hasTypedDigits: boolean; + onKeyPress: (event: TextInputKeyPressEvent) => void; onSelectionChange: (event: TextInputSelectionChangeEvent) => void; onChangeText: (text: string) => void; @@ -255,6 +258,7 @@ export default function useDateSegmentInput({value, mask, isEnabled, minDate, ma displayValue: value, selection: undefined, viewDate: undefined, + hasTypedDigits: false, onKeyPress: () => {}, onSelectionChange: () => {}, onChangeText: () => {}, @@ -267,6 +271,7 @@ export default function useDateSegmentInput({value, mask, isEnabled, minDate, ma displayValue: isEditing ? editingValue : value, selection: isEditing ? {start: caretPosition, end: caretPosition} : undefined, viewDate: isEditing ? viewDate : undefined, + hasTypedDigits: isEditing && hasAnySegment(segments), onKeyPress: handleKeyPress, onSelectionChange: handleSelectionChange, onChangeText: handleChangeText, From 7d4f9c69c69e28f14cc214b67f808a9ea6cec0a5 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 17 Sep 2026 22:34:28 +0530 Subject: [PATCH 09/23] fix: keep the typing calendar attached to its field while the page scrolls Signed-off-by: krishna2323 --- src/components/DatePicker/DatePickerModal.tsx | 2 ++ src/components/DatePicker/index.tsx | 21 +++++++++++++++++++ src/components/Popover/types.ts | 7 +++++++ src/components/PopoverProvider/index.tsx | 5 +++++ src/components/PopoverProvider/types.ts | 7 +++++++ .../PopoverWithoutOverlay/index.tsx | 2 ++ src/components/PopoverWithoutOverlay/types.ts | 6 ++++++ 7 files changed, 50 insertions(+) diff --git a/src/components/DatePicker/DatePickerModal.tsx b/src/components/DatePicker/DatePickerModal.tsx index 8df9742fc161..f372251242a6 100644 --- a/src/components/DatePicker/DatePickerModal.tsx +++ b/src/components/DatePicker/DatePickerModal.tsx @@ -49,6 +49,7 @@ function DatePickerModal({ anchorRef: anchorRefProp, withoutOverlay = false, shouldAllowWithoutOverlayInNarrowPane = false, + shouldCloseOnWheel = true, viewDate, }: DatePickerProps) { const [selectedDate, setSelectedDate] = useState(value ?? defaultValue ?? undefined); @@ -104,6 +105,7 @@ function DatePickerModal({ enableEdgeToEdgeBottomSafeAreaPadding withoutOverlay={withoutOverlay} shouldAllowWithoutOverlayInNarrowPane={shouldAllowWithoutOverlayInNarrowPane} + shouldCloseOnWheel={shouldCloseOnWheel} > debouncedCalculatePopoverPosition.cancel(); }, [calculatePopoverPosition, windowWidth]); + useEffect(() => { + // Only the typing calendar stays open while the page scrolls. Every other one is dismissed by PopoverProvider, + // so it never needs to follow the field. + if (!shouldAllowTyping || !isModalVisible) { + return; + } + + // The calendar is positioned from coordinates measured when it opened, so scrolling the form would leave it + // behind. Following the field keeps it attached rather than interrupting an edit in progress. + // Wrapped so the scroll event is not passed through as the measurement callback + const handleScroll = throttle(() => calculatePopoverPosition(), CONST.TIMING.MIN_SMOOTH_SCROLL_EVENT_THROTTLE); + document.addEventListener('scroll', handleScroll, true); + + return () => { + document.removeEventListener('scroll', handleScroll, true); + handleScroll.cancel(); + }; + }, [shouldAllowTyping, isModalVisible, calculatePopoverPosition]); + // Combined ref: updates textInputRef (needed for blur() in showDatePickerModal) and connects // autoFocusCallbackRef only when autoFocus=true so useAutoFocusInput's useFocusEffect cleanup // can cancel any pending focus task when the screen starts closing. @@ -313,6 +333,7 @@ function DatePicker({ anchorRef={anchorRef} withoutOverlay={shouldAllowTyping} shouldAllowWithoutOverlayInNarrowPane={shouldAllowTyping} + shouldCloseOnWheel={!shouldAllowTyping} viewDate={segmentInput.viewDate} /> diff --git a/src/components/Popover/types.ts b/src/components/Popover/types.ts index cee068ef7b1d..44fab5a4499c 100644 --- a/src/components/Popover/types.ts +++ b/src/components/Popover/types.ts @@ -26,6 +26,13 @@ type PopoverProps = BaseModalProps & */ shouldAllowWithoutOverlayInNarrowPane?: boolean; + /** + * Whether scrolling the page dismisses the popover. Only reaches an overlay-free popover, since the others + * cover the page and cannot be scrolled past in the first place. + * @default true + */ + shouldCloseOnWheel?: boolean; + popoverDimensions?: Dimensions; withoutOverlayRef?: RefObject; diff --git a/src/components/PopoverProvider/index.tsx b/src/components/PopoverProvider/index.tsx index d62d3defb351..fa56c2f1ec8a 100644 --- a/src/components/PopoverProvider/index.tsx +++ b/src/components/PopoverProvider/index.tsx @@ -125,6 +125,11 @@ function PopoverContextProvider(props: PopoverContextProps) { return; } + // A popover that keeps itself attached to its anchor has no reason to be dismissed by scrolling + if (activePopoverRef.current?.shouldCloseOnWheel === false) { + return; + } + closePopover(); }; document.addEventListener('wheel', listener, true); diff --git a/src/components/PopoverProvider/types.ts b/src/components/PopoverProvider/types.ts index 6a49012bbc0c..b477dbe4c94d 100644 --- a/src/components/PopoverProvider/types.ts +++ b/src/components/PopoverProvider/types.ts @@ -13,6 +13,13 @@ type AnchorRef = { close: (anchorRef?: RefObject) => void; anchorRef: RefObject; extraAnchorRefs?: Array>; + + /** + * Whether scrolling the page dismisses this popover. It is the right default for one that cannot follow its + * anchor, but false suits a popover that tracks the anchor itself, or one whose anchor is still being edited. + * @default true + */ + shouldCloseOnWheel?: boolean; }; export type {PopoverContextProps, AnchorRef}; diff --git a/src/components/PopoverWithoutOverlay/index.tsx b/src/components/PopoverWithoutOverlay/index.tsx index ea03d6b019de..ff867264b06b 100644 --- a/src/components/PopoverWithoutOverlay/index.tsx +++ b/src/components/PopoverWithoutOverlay/index.tsx @@ -32,6 +32,7 @@ function PopoverWithoutOverlay({ children, shouldDisplayBelowModals = false, enableEdgeToEdgeBottomSafeAreaPadding, + shouldCloseOnWheel = true, }: PopoverWithoutOverlayProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -62,6 +63,7 @@ function PopoverWithoutOverlay({ ref: withoutOverlayRef, close: onClose ?? NOOP, anchorRef, + shouldCloseOnWheel, }); removeOnClose = setCloseModal(onClose ?? NOOP); } else { diff --git a/src/components/PopoverWithoutOverlay/types.ts b/src/components/PopoverWithoutOverlay/types.ts index a0ded56a404b..5a74a20f2b23 100644 --- a/src/components/PopoverWithoutOverlay/types.ts +++ b/src/components/PopoverWithoutOverlay/types.ts @@ -25,6 +25,12 @@ type PopoverWithoutOverlayProps = ChildrenProps & /** Whether we should display the popover below other modals (e.g. SidePanel, RHP) */ shouldDisplayBelowModals?: boolean; + + /** + * Whether scrolling the page dismisses the popover, which it should unless the popover tracks its anchor. + * @default true + */ + shouldCloseOnWheel?: boolean; }; export default PopoverWithoutOverlayProps; From fc6df11ab212ff527de91383828b7ad22d797c22 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 17 Sep 2026 22:45:03 +0530 Subject: [PATCH 10/23] fix: keep the calendar open while the year or month picker is used Signed-off-by: krishna2323 --- .../CalendarPicker/MonthPickerModal.tsx | 18 +++++++++++++++++- .../CalendarPicker/YearPickerModal.tsx | 18 +++++++++++++++++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/components/DatePicker/CalendarPicker/MonthPickerModal.tsx b/src/components/DatePicker/CalendarPicker/MonthPickerModal.tsx index bdb1545a68f7..ac1e89d0db42 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,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 = { @@ -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(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(() => { @@ -75,6 +90,7 @@ function MonthPickerModal({isVisible, currentMonth = new Date().getMonth(), onMo 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]); const {data, headerMessage} = useMemo(() => { const yearsList = searchText === '' ? years : years.filter((year) => year.text?.includes(searchText)); return { @@ -71,6 +86,7 @@ function YearPickerModal({isVisible, years, currentYear = new Date().getFullYear enableEdgeToEdgeBottomSafeAreaPadding > Date: Thu, 17 Sep 2026 23:02:34 +0530 Subject: [PATCH 11/23] refactor: remove the workarounds that accumulated around the typed date field Signed-off-by: krishna2323 --- src/components/DatePicker/index.tsx | 53 +++++++------------ src/components/Popover/index.tsx | 7 +-- src/components/Popover/types.ts | 6 +-- src/hooks/useDateSegmentInput.ts | 14 ++--- .../useRemeasureOnScroll/index.native.ts | 9 ++++ src/hooks/useRemeasureOnScroll/index.ts | 29 ++++++++++ src/hooks/useRemeasureOnScroll/types.ts | 12 +++++ 7 files changed, 79 insertions(+), 51 deletions(-) create mode 100644 src/hooks/useRemeasureOnScroll/index.native.ts create mode 100644 src/hooks/useRemeasureOnScroll/index.ts create mode 100644 src/hooks/useRemeasureOnScroll/types.ts diff --git a/src/components/DatePicker/index.tsx b/src/components/DatePicker/index.tsx index 262cfaeba3e7..71c9c9870921 100644 --- a/src/components/DatePicker/index.tsx +++ b/src/components/DatePicker/index.tsx @@ -6,6 +6,7 @@ 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 useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; @@ -21,7 +22,6 @@ import type {TextInputKeyPressEvent} from 'react-native'; import {format, setYear} from 'date-fns'; import debounce from 'lodash/debounce'; -import throttle from 'lodash/throttle'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {Keyboard, View} from 'react-native'; @@ -81,6 +81,8 @@ function DatePicker({ onInputChange?.(newDate); }; + // 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, mask: dateMask, isEnabled: shouldAllowTyping, minDate, maxDate, onCommit: handleTypedDate}); const {inputCallbackRef: autoFocusCallbackRef, cancelAutoFocus} = useAutoFocusInput(); @@ -127,6 +129,12 @@ function DatePicker({ ); 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; + } + cancelAutoFocus(); // While typing is allowed the calendar sits under an input the user is still writing in, so the caret has to @@ -159,7 +167,7 @@ function DatePicker({ }; openPicker(); - }, [shouldDeferShowUntilPositioned, shouldDismissKeyboardBeforeShow, shouldAllowTyping, calculatePopoverPosition, cancelAutoFocus, setPickerVisibility]); + }, [isModalVisible, shouldDeferShowUntilPositioned, shouldDismissKeyboardBeforeShow, shouldAllowTyping, calculatePopoverPosition, cancelAutoFocus, setPickerVisibility]); const closeDatePicker = useCallback(() => { openIntentRef.current = false; @@ -181,24 +189,14 @@ function DatePicker({ event.preventDefault(); } - // Clicking from one segment to another must not remeasure and reopen a calendar that is already showing. - if (shouldAllowTyping && isModalVisible) { - return; - } - showDatePickerModal(); }, - [shouldAllowTyping, isModalVisible, showDatePickerModal], + [shouldAllowTyping, showDatePickerModal], ); // Reaching the field by keyboard never fires a press, so focus is what opens the calendar once typing is allowed. const handleFocus = () => { segmentInput.onFocus(); - - if (isModalVisible) { - return; - } - showDatePickerModal(); }; @@ -221,6 +219,10 @@ function DatePicker({ 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}); + const handleClear = () => { onTouched?.(); onInputChange?.(''); @@ -236,25 +238,6 @@ function DatePicker({ return () => debouncedCalculatePopoverPosition.cancel(); }, [calculatePopoverPosition, windowWidth]); - useEffect(() => { - // Only the typing calendar stays open while the page scrolls. Every other one is dismissed by PopoverProvider, - // so it never needs to follow the field. - if (!shouldAllowTyping || !isModalVisible) { - return; - } - - // The calendar is positioned from coordinates measured when it opened, so scrolling the form would leave it - // behind. Following the field keeps it attached rather than interrupting an edit in progress. - // Wrapped so the scroll event is not passed through as the measurement callback - const handleScroll = throttle(() => calculatePopoverPosition(), CONST.TIMING.MIN_SMOOTH_SCROLL_EVENT_THROTTLE); - document.addEventListener('scroll', handleScroll, true); - - return () => { - document.removeEventListener('scroll', handleScroll, true); - handleScroll.cancel(); - }; - }, [shouldAllowTyping, isModalVisible, calculatePopoverPosition]); - // Combined ref: updates textInputRef (needed for blur() in showDatePickerModal) and connects // autoFocusCallbackRef only when autoFocus=true so useAutoFocusInput's useFocusEffect cleanup // can cancel any pending focus task when the screen starts closing. @@ -304,9 +287,9 @@ function DatePicker({ onPress={shouldDismissKeyboardBeforeShow || shouldAllowTyping ? handlePress : () => showDatePickerModal()} onSubmitEditing={shouldAllowTyping ? undefined : () => showDatePickerModal()} onFocus={shouldAllowTyping ? handleFocus : undefined} - onBlur={shouldAllowTyping ? segmentInput.onBlur : undefined} - onChangeText={shouldAllowTyping ? segmentInput.onChangeText : undefined} - onSelectionChange={shouldAllowTyping ? segmentInput.onSelectionChange : undefined} + onBlur={segmentInput.onBlur} + onChangeText={segmentInput.onChangeText} + onSelectionChange={segmentInput.onSelectionChange} onKeyPress={shouldAllowTyping ? segmentInput.onKeyPress : handleInputKeyPress} textInputContainerStyles={isModalVisible ? styles.borderColorFocus : {}} shouldHideClearButton={shouldHideClearButton} diff --git a/src/components/Popover/index.tsx b/src/components/Popover/index.tsx index 3add94a1ab28..819cfd61b21b 100644 --- a/src/components/Popover/index.tsx +++ b/src/components/Popover/index.tsx @@ -134,9 +134,10 @@ function Popover(props: PopoverProps) { ); } - // A narrow pane normally forces the full modal. An opting-in caller keeps the overlay-free popover there, but a - // small screen never does, since there is no room for a popover beside the control that opened it. - const canSkipOverlay = !shouldUseNarrowLayout || (shouldAllowWithoutOverlayInNarrowPane && !isSmallScreenWidth); + // An overlay-free popover normally needs room beside its anchor, which a narrow pane is assumed not to have. An + // opting-in caller is judged on screen size alone, so it keeps the popover in the RHP on a wide screen and still + // gets the full modal on a small one. + const canSkipOverlay = shouldAllowWithoutOverlayInNarrowPane ? !isSmallScreenWidth : !shouldUseNarrowLayout; if (withoutOverlay && canSkipOverlay) { return createPortal( diff --git a/src/components/Popover/types.ts b/src/components/Popover/types.ts index 44fab5a4499c..54fdbc10b51e 100644 --- a/src/components/Popover/types.ts +++ b/src/components/Popover/types.ts @@ -20,9 +20,9 @@ type PopoverProps = BaseModalProps & withoutOverlay?: boolean; /** - * Honours `withoutOverlay` inside a narrow pane such as the RHP, where it is otherwise ignored in favour of a - * full modal. Only for a popover that must leave the control that opened it usable, such as the calendar the - * user keeps typing a date into. Small screens still get the full modal either way. + * Judges `withoutOverlay` on screen size rather than on whether this is a narrow pane, so the popover survives + * inside the RHP on a wide screen. Only for one that must leave the control that opened it usable, such as the + * calendar the user keeps typing a date into. A small screen still gets the full modal. */ shouldAllowWithoutOverlayInNarrowPane?: boolean; diff --git a/src/hooks/useDateSegmentInput.ts b/src/hooks/useDateSegmentInput.ts index e55b6f190993..2dfa70d5f9ce 100644 --- a/src/hooks/useDateSegmentInput.ts +++ b/src/hooks/useDateSegmentInput.ts @@ -99,16 +99,10 @@ export default function useDateSegmentInput({value, mask, isEnabled, minDate, ma // highlight it as a block, which is not what the design asks for. const caretPosition = activeRange.start + caretOffset; - /** - * A caret may rest on any digit place already typed, or just after the last of them, but never out on a mask - * letter. An empty segment therefore only ever has its start, which is what stops a click landing on a bare Y. - */ - const getFurthestOffset = (name: DateSegmentName, currentSegments: DateSegments) => getCaretOffsetLimit(currentSegments, name); - const moveCaret = (name: DateSegmentName, offset: number, nextSegments: DateSegments = segments) => { hasPendingCaretEchoRef.current = true; setActiveSegmentName(name); - setCaretOffset(Math.min(Math.max(offset, 0), getFurthestOffset(name, nextSegments))); + setCaretOffset(Math.min(Math.max(offset, 0), getCaretOffsetLimit(nextSegments, name))); }; /** @@ -118,7 +112,7 @@ export default function useDateSegmentInput({value, mask, isEnabled, minDate, ma */ const enterSegment = (name: DateSegmentName, nextSegments: DateSegments = segments) => { shouldOverwriteRef.current = true; - moveCaret(name, getFurthestOffset(name, nextSegments), nextSegments); + moveCaret(name, getCaretOffsetLimit(nextSegments, name), nextSegments); }; const commitIfComplete = (newSegments: DateSegments) => { @@ -159,7 +153,7 @@ export default function useDateSegmentInput({value, mask, isEnabled, minDate, ma return; } - moveCaret(activeSegmentName, getFurthestOffset(activeSegmentName, result.segments), result.segments); + moveCaret(activeSegmentName, getCaretOffsetLimit(result.segments, activeSegmentName), result.segments); return; } @@ -181,7 +175,7 @@ export default function useDateSegmentInput({value, mask, isEnabled, minDate, ma applySegments(trimmedSegments); shouldOverwriteRef.current = false; - moveCaret(activeSegmentName, getFurthestOffset(activeSegmentName, trimmedSegments), trimmedSegments); + moveCaret(activeSegmentName, getCaretOffsetLimit(trimmedSegments, activeSegmentName), trimmedSegments); return; } diff --git a/src/hooks/useRemeasureOnScroll/index.native.ts b/src/hooks/useRemeasureOnScroll/index.native.ts new file mode 100644 index 000000000000..8f615444b3cb --- /dev/null +++ b/src/hooks/useRemeasureOnScroll/index.native.ts @@ -0,0 +1,9 @@ +/** + * Native has no document to listen to, and nothing anchored to a scrolling page to keep in place, so there is nothing + * to remeasure. + */ +import type UseRemeasureOnScroll from './types'; + +const useRemeasureOnScroll: UseRemeasureOnScroll = () => {}; + +export default useRemeasureOnScroll; diff --git a/src/hooks/useRemeasureOnScroll/index.ts b/src/hooks/useRemeasureOnScroll/index.ts new file mode 100644 index 000000000000..348bc122faf2 --- /dev/null +++ b/src/hooks/useRemeasureOnScroll/index.ts @@ -0,0 +1,29 @@ +/** + * Keeps something positioned from a one-off measurement attached to what it was measured against, by remeasuring as + * the page scrolls. A capture phase listener is used because scroll events do not bubble, so this covers the inner + * scroll containers a screen is built from as well as the document itself. + */ +import CONST from '@src/CONST'; + +import throttle from 'lodash/throttle'; +import {useEffect} from 'react'; + +import type UseRemeasureOnScroll from './types'; + +const useRemeasureOnScroll: UseRemeasureOnScroll = ({isActive, remeasure}) => { + useEffect(() => { + if (!isActive) { + return; + } + + const handleScroll = throttle(remeasure, CONST.TIMING.MIN_SMOOTH_SCROLL_EVENT_THROTTLE); + document.addEventListener('scroll', handleScroll, true); + + return () => { + document.removeEventListener('scroll', handleScroll, true); + handleScroll.cancel(); + }; + }, [isActive, remeasure]); +}; + +export default useRemeasureOnScroll; diff --git a/src/hooks/useRemeasureOnScroll/types.ts b/src/hooks/useRemeasureOnScroll/types.ts new file mode 100644 index 000000000000..cee7bc213303 --- /dev/null +++ b/src/hooks/useRemeasureOnScroll/types.ts @@ -0,0 +1,12 @@ +type UseRemeasureOnScrollParams = { + /** Whether anything is currently anchored and so worth keeping in place */ + isActive: boolean; + + /** Takes a fresh measurement of the anchor */ + remeasure: () => void; +}; + +type UseRemeasureOnScroll = (params: UseRemeasureOnScrollParams) => void; + +export default UseRemeasureOnScroll; +export type {UseRemeasureOnScrollParams}; From cf9bbd1dbc1b6fa298e6fa78da40265600941bce Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 17 Sep 2026 23:24:43 +0530 Subject: [PATCH 12/23] feat: update the date field when a month or year is picked Signed-off-by: krishna2323 --- .../DatePicker/CalendarPicker/index.tsx | 48 +++++++--- src/components/DatePicker/DatePickerModal.tsx | 17 +++- src/components/DatePicker/index.tsx | 7 +- src/components/DatePicker/types.ts | 9 ++ src/hooks/useDateSegmentInput.ts | 39 ++++++-- tests/unit/CalendarPickerTest.tsx | 92 +++++++++++++++++++ 6 files changed, 186 insertions(+), 26 deletions(-) diff --git a/src/components/DatePicker/CalendarPicker/index.tsx b/src/components/DatePicker/CalendarPicker/index.tsx index 73aa26e33b5a..ce6f945b6d05 100644 --- a/src/components/DatePicker/CalendarPicker/index.tsx +++ b/src/components/DatePicker/CalendarPicker/index.tsx @@ -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'; @@ -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; @@ -58,6 +64,9 @@ type CalendarPickerProps = { * 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) { @@ -81,17 +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(); @@ -101,7 +118,7 @@ function CalendarPicker({ const pressableRef = useRef(null); const monthPressableRef = useRef(null); const [currentDateView, setCurrentDateView] = useState(() => getInitialCurrentDateView(value, minDate, maxDate)); - const [appliedViewDate, setAppliedViewDate] = useState(viewDate); + const [appliedViewDateVersion, setAppliedViewDateVersion] = useState(viewDateVersion); const [isYearPickerVisible, setIsYearPickerVisible] = useState(false); const [isMonthPickerVisible, setIsMonthPickerVisible] = useState(false); const isFirstRender = useRef(true); @@ -109,8 +126,8 @@ function CalendarPicker({ // 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 && viewDate.getTime() !== appliedViewDate?.getTime()) { - setAppliedViewDate(viewDate); + if (viewDate && viewDateVersion !== appliedViewDateVersion) { + setAppliedViewDateVersion(viewDateVersion); setCurrentDateView(viewDate); } @@ -133,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)); }; diff --git a/src/components/DatePicker/DatePickerModal.tsx b/src/components/DatePicker/DatePickerModal.tsx index f372251242a6..160a31826898 100644 --- a/src/components/DatePicker/DatePickerModal.tsx +++ b/src/components/DatePicker/DatePickerModal.tsx @@ -42,6 +42,7 @@ function DatePickerModal({ anchorPosition, anchorAlignment = DEFAULT_ANCHOR_ORIGIN, onSelected, + onMonthOrYearSelected, shouldCloseWhenBrowserNavigationChanged = false, shouldPositionFromTop = false, forwardedFSClass, @@ -51,6 +52,7 @@ function DatePickerModal({ shouldAllowWithoutOverlayInNarrowPane = false, shouldCloseOnWheel = true, viewDate, + viewDateVersion, }: DatePickerProps) { const [selectedDate, setSelectedDate] = useState(value ?? defaultValue ?? undefined); const fallbackAnchorRef = useRef(null); @@ -72,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 — @@ -112,9 +123,11 @@ function DatePickerModal({ maxDate={maxDate} value={selectedDate} onSelected={handleDateSelection} + onMonthOrYearSelected={onMonthOrYearSelected ? handleMonthOrYearSelection : undefined} containerStyle={bottomSafeAreaPaddingStyle} shouldEnableMonthYearBackdropInNarrowPane={shouldEnableMonthYearBackdropInNarrowPane} viewDate={viewDate} + viewDateVersion={viewDateVersion} /> ); diff --git a/src/components/DatePicker/index.tsx b/src/components/DatePicker/index.tsx index 71c9c9870921..692c5be609df 100644 --- a/src/components/DatePicker/index.tsx +++ b/src/components/DatePicker/index.tsx @@ -75,7 +75,8 @@ function DatePicker({ const shouldAllowTyping = isTypedDateInputSupported(); const dateMask = translate('common.dateFormat'); - const handleTypedDate = (newDate: string) => { + // Updates the field without ending the selection, so the calendar stays open for whatever the user does next + const commitDate = (newDate: string) => { setSelectedDate(newDate); onTouched?.(); onInputChange?.(newDate); @@ -83,7 +84,7 @@ function DatePicker({ // 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, mask: dateMask, isEnabled: shouldAllowTyping, minDate, maxDate, onCommit: handleTypedDate}); + const segmentInput = useDateSegmentInput({value: selectedDate, mask: dateMask, isEnabled: shouldAllowTyping, minDate, maxDate, onCommit: commitDate}); const {inputCallbackRef: autoFocusCallbackRef, cancelAutoFocus} = useAutoFocusInput(); const autoFocusCallbackRefRef = useRef(autoFocusCallbackRef); @@ -318,6 +319,8 @@ function DatePicker({ shouldAllowWithoutOverlayInNarrowPane={shouldAllowTyping} shouldCloseOnWheel={!shouldAllowTyping} viewDate={segmentInput.viewDate} + viewDateVersion={segmentInput.viewDateVersion} + onMonthOrYearSelected={commitDate} /> ); diff --git a/src/components/DatePicker/types.ts b/src/components/DatePicker/types.ts index 1fb1dec3ac40..22cad09fe57b 100644 --- a/src/components/DatePicker/types.ts +++ b/src/components/DatePicker/types.ts @@ -147,6 +147,15 @@ type DatePickerProps = { /** 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; } & Omit; export type {DateInputWithPickerProps, DatePickerProps}; diff --git a/src/hooks/useDateSegmentInput.ts b/src/hooks/useDateSegmentInput.ts index 2dfa70d5f9ce..35748e7fc722 100644 --- a/src/hooks/useDateSegmentInput.ts +++ b/src/hooks/useDateSegmentInput.ts @@ -65,6 +65,9 @@ type UseDateSegmentInputResult = { /** The month the calendar should show, so it follows the date being typed. Undefined leaves the calendar alone */ viewDate: Date | undefined; + /** Counts how many times the input has asked the calendar to follow it, so asking twice for the same month counts twice */ + viewDateVersion: number; + /** Whether any digit has been typed, so the field is showing more than an untouched mask */ hasTypedDigits: boolean; @@ -87,11 +90,23 @@ export default function useDateSegmentInput({value, mask, isEnabled, minDate, ma const [isEditing, setIsEditing] = useState(false); // The month the calendar should show, which follows the typed date once the year is complete const [viewDate, setViewDate] = useState(undefined); + const [viewDateVersion, setViewDateVersion] = useState(0); // Re-rendering with a new value makes the browser report a caret of its own choosing. Honouring that would drag // the active segment around, so the first report after a keystroke is discarded as an echo of our own update. const hasPendingCaretEchoRef = useRef(false); // Whether the next digit replaces the active segment instead of extending it, set on arriving at a segment const shouldOverwriteRef = useRef(false); + const [appliedValue, setAppliedValue] = useState(value); + + // A date set from outside, by the calendar or by a restored draft, has to reach the segments as well. Without this + // an edit in progress would keep showing the date it started from, since the segments are what the field renders. + if (value !== appliedValue) { + setAppliedValue(value); + + if (isEditing) { + setSegments(getSegmentsFromISODate(value)); + } + } const {value: editingValue, ranges} = getDateDisplay(segments, mask); const activeRange = ranges[activeSegmentName]; @@ -124,18 +139,26 @@ export default function useDateSegmentInput({value, mask, isEnabled, minDate, ma onCommit(isoDate); }; + /** + * Asks the calendar to follow the input. The count is what carries the request, since the user can have moved the + * calendar elsewhere with its arrows or its month picker and then typed the month it was already showing. + */ + const assertViewDate = (nextViewDate: Date | undefined) => { + if (!nextViewDate) { + return; + } + + setViewDate(nextViewDate); + setViewDateVersion((version) => version + 1); + }; + /** * The one place segments are written, so the calendar cannot fall out of step with them. The month already on * screen is the fallback while the typed month is unfinished, which keeps the calendar where the user left it. */ const applySegments = (newSegments: DateSegments) => { setSegments(newSegments); - - const nextViewDate = getViewDateFromSegments(newSegments, (viewDate ?? new Date()).getMonth(), minDate, maxDate); - if (nextViewDate) { - setViewDate(nextViewDate); - } - + assertViewDate(getViewDateFromSegments(newSegments, (viewDate ?? new Date()).getMonth(), minDate, maxDate)); commitIfComplete(newSegments); }; @@ -228,7 +251,7 @@ export default function useDateSegmentInput({value, mask, isEnabled, minDate, ma const firstSegmentName = getFirstUnfilledSegmentName(seededSegments) ?? FIRST_SEGMENT_NAME; setSegments(seededSegments); - setViewDate(getViewDateFromSegments(seededSegments, new Date().getMonth(), minDate, maxDate)); + assertViewDate(getViewDateFromSegments(seededSegments, new Date().getMonth(), minDate, maxDate)); shouldOverwriteRef.current = false; setActiveSegmentName(firstSegmentName); setCaretOffset(getCaretOffsetLimit(seededSegments, firstSegmentName)); @@ -252,6 +275,7 @@ export default function useDateSegmentInput({value, mask, isEnabled, minDate, ma displayValue: value, selection: undefined, viewDate: undefined, + viewDateVersion: 0, hasTypedDigits: false, onKeyPress: () => {}, onSelectionChange: () => {}, @@ -265,6 +289,7 @@ export default function useDateSegmentInput({value, mask, isEnabled, minDate, ma displayValue: isEditing ? editingValue : value, selection: isEditing ? {start: caretPosition, end: caretPosition} : undefined, viewDate: isEditing ? viewDate : undefined, + viewDateVersion, hasTypedDigits: isEditing && hasAnySegment(segments), onKeyPress: handleKeyPress, onSelectionChange: handleSelectionChange, diff --git a/tests/unit/CalendarPickerTest.tsx b/tests/unit/CalendarPickerTest.tsx index da71b09cf8d4..8d3e04388d71 100644 --- a/tests/unit/CalendarPickerTest.tsx +++ b/tests/unit/CalendarPickerTest.tsx @@ -624,6 +624,98 @@ describe('CalendarPicker', () => { expect(within(screen.getByTestId('currentYearText')).getByText('2027')).toBeTruthy(); }); + test('selecting a year reports the date it lands on without ending the selection', () => { + const onSelectedMock = jest.fn(); + const onMonthOrYearSelectedMock = jest.fn(); + render( + , + ); + + fireEvent.press(screen.getByTestId('currentYearButton')); + fireEvent.press(within(screen.getByTestId('YearPickerModal')).getByTestId('year-option-2027')); + + expect(onMonthOrYearSelectedMock).toHaveBeenCalledWith('2027-06-15'); + expect(onSelectedMock).not.toHaveBeenCalled(); + }); + + test('selecting a month reports the date it lands on without ending the selection', () => { + const onSelectedMock = jest.fn(); + const onMonthOrYearSelectedMock = jest.fn(); + render( + , + ); + + fireEvent.press(screen.getByTestId('currentMonthButton')); + fireEvent.press(within(screen.getByTestId('MonthPickerModal')).getByTestId('month-option-8')); + + expect(onMonthOrYearSelectedMock).toHaveBeenCalledWith('2025-09-15'); + expect(onSelectedMock).not.toHaveBeenCalled(); + }); + + test('selecting a month keeps the day inside it', () => { + const onMonthOrYearSelectedMock = jest.fn(); + render( + , + ); + + fireEvent.press(screen.getByTestId('currentMonthButton')); + fireEvent.press(within(screen.getByTestId('MonthPickerModal')).getByTestId('month-option-1')); + + expect(onMonthOrYearSelectedMock).toHaveBeenCalledWith('2025-02-28'); + }); + + test('selecting a non leap year from February 29 keeps the day inside February', () => { + const onMonthOrYearSelectedMock = jest.fn(); + render( + , + ); + + fireEvent.press(screen.getByTestId('currentYearButton')); + fireEvent.press(within(screen.getByTestId('YearPickerModal')).getByTestId('year-option-2025')); + + expect(onMonthOrYearSelectedMock).toHaveBeenCalledWith('2025-02-28'); + }); + + test('a month or year selection only moves the view when the caller does not ask to be told', () => { + const onSelectedMock = jest.fn(); + render( + , + ); + + fireEvent.press(screen.getByTestId('currentYearButton')); + fireEvent.press(within(screen.getByTestId('YearPickerModal')).getByTestId('year-option-2027')); + + expect(within(screen.getByTestId('currentYearText')).getByText('2027')).toBeTruthy(); + expect(onSelectedMock).not.toHaveBeenCalled(); + }); + test('closing the year picker via onClose hides the modal', () => { render(); From 6848a7448947291dc360c98f04e0438f12a1f9da Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 17 Sep 2026 23:35:52 +0530 Subject: [PATCH 13/23] fix: stop a stray caret report moving the date field's active segment Signed-off-by: krishna2323 --- src/hooks/useDateSegmentInput.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/hooks/useDateSegmentInput.ts b/src/hooks/useDateSegmentInput.ts index 35748e7fc722..eea4a2d705d3 100644 --- a/src/hooks/useDateSegmentInput.ts +++ b/src/hooks/useDateSegmentInput.ts @@ -224,9 +224,15 @@ export default function useDateSegmentInput({value, mask, isEnabled, minDate, ma const position = event.nativeEvent.selection.start; + // The caret is already here, so this is the browser reporting our own position back rather than the user + // aiming somewhere. Acting on it is what let a stray report drag the active segment around. + if (position === caretPosition) { + return; + } + // Landing past the end of the text means the empty space in the field was clicked rather than a segment, so - // the first segment still to be filled in takes it, which is the year on an untouched field. - const clickedSegmentName = position >= editingValue.length ? (getFirstUnfilledSegmentName(segments) ?? LAST_SEGMENT_NAME) : getSegmentNameAtPosition(position, ranges); + // the first segment still to be filled in takes it, and a date with no gaps in it starts again from the year. + const clickedSegmentName = position >= editingValue.length ? (getFirstUnfilledSegmentName(segments) ?? FIRST_SEGMENT_NAME) : getSegmentNameAtPosition(position, ranges); const clickedOffset = position - ranges[clickedSegmentName].start; // Clicking is aiming at a digit place rather than arriving at a segment, so the next digit extends what is From c5345240a456eca1970d40b9e7e60efc1df99179 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 18 Sep 2026 23:06:54 +0530 Subject: [PATCH 14/23] refactor: rebuild the date field as one input per segment Signed-off-by: krishna2323 --- src/components/DatePicker/index.tsx | 30 ++- src/components/DateSegmentsInput.tsx | 117 +++++++++++ .../BaseTextInput/implementations.ts | 2 + .../TextInput/BaseTextInput/types.ts | 22 +- src/hooks/useDateSegmentInput.ts | 198 ++++++++---------- src/languages/de.ts | 5 + src/languages/el.ts | 5 + src/languages/en.ts | 5 + src/languages/es.ts | 5 + src/languages/fr.ts | 5 + src/languages/it.ts | 5 + src/languages/ja.ts | 5 + src/languages/nl.ts | 5 + src/languages/pl.ts | 5 + src/languages/pt-BR.ts | 5 + src/languages/zh-hans.ts | 5 + src/libs/DateInputMaskUtils.ts | 61 +----- tests/unit/DateInputMaskUtilsTest.ts | 90 +++----- 18 files changed, 343 insertions(+), 232 deletions(-) create mode 100644 src/components/DateSegmentsInput.tsx diff --git a/src/components/DatePicker/index.tsx b/src/components/DatePicker/index.tsx index 692c5be609df..355df62ef44f 100644 --- a/src/components/DatePicker/index.tsx +++ b/src/components/DatePicker/index.tsx @@ -84,7 +84,7 @@ function DatePicker({ // 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, mask: dateMask, isEnabled: shouldAllowTyping, minDate, maxDate, onCommit: commitDate}); + const segmentInput = useDateSegmentInput({value: selectedDate, isEnabled: shouldAllowTyping, minDate, maxDate, onCommit: commitDate}); const {inputCallbackRef: autoFocusCallbackRef, cancelAutoFocus} = useAutoFocusInput(); const autoFocusCallbackRefRef = useRef(autoFocusCallbackRef); @@ -185,19 +185,24 @@ function DatePicker({ const handlePress = useCallback>( (event) => { - // Preventing the press would also stop the caret from landing in the segment the user clicked. + // Preventing the press would also stop focus from landing in the segment the user clicked. if (!shouldAllowTyping && 'preventDefault' in event) { event.preventDefault(); } + // A press that landed on the field but not on a segment leaves nothing focused, so send it somewhere useful + if (shouldAllowTyping && !segmentInput.isEditing) { + segmentInput.requestInitialFocus(); + } + showDatePickerModal(); }, - [shouldAllowTyping, showDatePickerModal], + [shouldAllowTyping, showDatePickerModal, segmentInput], ); // Reaching the field by keyboard never fires a press, so focus is what opens the calendar once typing is allowed. + // The segments report their own focus to the hook, so there is nothing to seed here. const handleFocus = () => { - segmentInput.onFocus(); showDatePickerModal(); }; @@ -278,8 +283,18 @@ function DatePicker({ accessibilityLabel={label} role={CONST.ROLE.COMBOBOX} accessibilityState={{expanded: isModalVisible}} + type={shouldAllowTyping ? 'dateSegments' : 'default'} + dateSegmentsConfig={ + shouldAllowTyping + ? { + mask: dateMask, + getSegmentProps: segmentInput.getSegmentProps, + focusRequest: segmentInput.focusRequest, + onFieldBlur: segmentInput.onFieldBlur, + } + : undefined + } value={segmentInput.displayValue} - selection={segmentInput.selection} placeholder={placeholder ?? dateMask} errorText={errorText} inputStyle={shouldAllowTyping ? undefined : styles.pointerEventsNone} @@ -288,10 +303,7 @@ function DatePicker({ onPress={shouldDismissKeyboardBeforeShow || shouldAllowTyping ? handlePress : () => showDatePickerModal()} onSubmitEditing={shouldAllowTyping ? undefined : () => showDatePickerModal()} onFocus={shouldAllowTyping ? handleFocus : undefined} - onBlur={segmentInput.onBlur} - onChangeText={segmentInput.onChangeText} - onSelectionChange={segmentInput.onSelectionChange} - onKeyPress={shouldAllowTyping ? segmentInput.onKeyPress : handleInputKeyPress} + onKeyPress={shouldAllowTyping ? undefined : handleInputKeyPress} textInputContainerStyles={isModalVisible ? styles.borderColorFocus : {}} shouldHideClearButton={shouldHideClearButton} onClearInput={handleClear} diff --git a/src/components/DateSegmentsInput.tsx b/src/components/DateSegmentsInput.tsx new file mode 100644 index 000000000000..6c87251070eb --- /dev/null +++ b/src/components/DateSegmentsInput.tsx @@ -0,0 +1,117 @@ +/** + * The inner part of the date field, rendering one input per date segment instead of one input for the whole date. It is + * registered as a `BaseTextInput` input type, so it sits inside the same label, border and error chrome as any other + * text field. + * + * One input per segment is what makes the caret a non issue: moving between segments is an ordinary focus change, so + * nothing here has to reason about where a caret sits inside a longer string. + */ +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {getDateMaskParts} from '@libs/DateInputMaskUtils'; +import type {DateSegmentName} from '@libs/DateInputMaskUtils'; + +import CONST from '@src/CONST'; + +import React, {useEffect, useRef} from 'react'; +import {View} from 'react-native'; + +import type {AnimatedTextInputRef} from './RNTextInput'; +import type {BaseTextInputProps} from './TextInput/BaseTextInput/types'; + +import RNTextInput from './RNTextInput'; +import Text from './Text'; + +/** The mask letters are wider than the digits that replace them, so a segment is sized to hold its placeholder */ +const SEGMENT_PADDING = CONST.CHARACTER_WIDTH / 2; + +/** + * The style the field hands down stretches its input to fill the row, which would spread three of them evenly across + * it. Each segment is sized to its own digits instead, so the date reads as one run of text. + */ +const SIZED_TO_CONTENT = {flexGrow: 0, flexShrink: 0, flexBasis: 'auto'} as const; + +function DateSegmentsInput({dateSegmentsConfig, style, placeholderTextColor, disabled, onPressOut, forwardedFSClass, ref}: BaseTextInputProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const segmentRefs = useRef>>({}); + // Focus moves between segments as one blur followed by one focus, so leaving the field can only be told apart from + // moving within it once the next focus has had its chance to arrive. + const blurTimeoutRef = useRef(undefined); + const appliedFocusVersionRef = useRef(0); + + const focusRequest = dateSegmentsConfig?.focusRequest; + + useEffect(() => { + if (!focusRequest || focusRequest.version === appliedFocusVersionRef.current) { + return; + } + + appliedFocusVersionRef.current = focusRequest.version; + segmentRefs.current[focusRequest.name]?.focus(); + }, [focusRequest]); + + useEffect(() => () => clearTimeout(blurTimeoutRef.current), []); + + if (!dateSegmentsConfig) { + return null; + } + + const {mask, getSegmentProps, onFieldBlur} = dateSegmentsConfig; + + const handleSegmentBlur = () => { + clearTimeout(blurTimeoutRef.current); + blurTimeoutRef.current = setTimeout(onFieldBlur, 0); + }; + + return ( + + {getDateMaskParts(mask).map((part) => { + const segmentProps = getSegmentProps(part.name); + + return ( + + { + segmentRefs.current[part.name] = element; + + // The field's own ref has to lead somewhere, and the year is where typing starts + if (part.name !== 'year') { + return; + } + if (typeof ref === 'function') { + ref(element); + } else if (ref && 'current' in ref) { + // eslint-disable-next-line no-param-reassign + ref.current = element; + } + }} + style={[style, styles.p0, SIZED_TO_CONTENT, {width: part.placeholder.length * CONST.CHARACTER_WIDTH + SEGMENT_PADDING}]} + value={segmentProps.value} + placeholder={part.placeholder} + placeholderTextColor={placeholderTextColor} + onKeyPress={segmentProps.onKeyPress} + onChangeText={segmentProps.onChangeText} + onFocus={() => { + clearTimeout(blurTimeoutRef.current); + segmentProps.onFocus(); + }} + onBlur={handleSegmentBlur} + onPressOut={onPressOut} + accessibilityLabel={translate(`common.dateSegments.${part.name}`)} + inputMode="numeric" + disabled={disabled} + forwardedFSClass={forwardedFSClass} + /> + {!!part.separator && {part.separator}} + + ); + })} + + ); +} + +DateSegmentsInput.displayName = 'DateSegmentsInput'; + +export default DateSegmentsInput; diff --git a/src/components/TextInput/BaseTextInput/implementations.ts b/src/components/TextInput/BaseTextInput/implementations.ts index ccb8096335b9..d90dc7229dae 100644 --- a/src/components/TextInput/BaseTextInput/implementations.ts +++ b/src/components/TextInput/BaseTextInput/implementations.ts @@ -1,3 +1,4 @@ +import DateSegmentsInput from '@components/DateSegmentsInput'; import RNMarkdownTextInput from '@components/RNMarkdownTextInput'; import RNMaskedTextInput from '@components/RNMaskedTextInput'; import RNTextInput from '@components/RNTextInput'; @@ -10,6 +11,7 @@ const InputComponentMap = new Map([ ['default', RNTextInput as InputComponentType], ['mask', RNMaskedTextInput as InputComponentType], ['markdown', RNMarkdownTextInput as InputComponentType], + ['dateSegments', DateSegmentsInput as InputComponentType], ]); export default InputComponentMap; diff --git a/src/components/TextInput/BaseTextInput/types.ts b/src/components/TextInput/BaseTextInput/types.ts index 07cf3fbce236..5a618c50b0e2 100644 --- a/src/components/TextInput/BaseTextInput/types.ts +++ b/src/components/TextInput/BaseTextInput/types.ts @@ -1,5 +1,7 @@ import type {AnimatedTextInputRef} from '@components/RNTextInput'; +import type {UseDateSegmentInputResult} from '@hooks/useDateSegmentInput'; + import type {ForwardedFSClassProps} from '@libs/Fullstory/types'; import type IconAsset from '@src/types/utils/IconAsset'; @@ -11,7 +13,18 @@ import type {ForwardedRef} from 'react'; import type {GestureResponderEvent, StyleProp, TextInputProps, TextStyle, ViewStyle} from 'react-native'; import type {MaskedTextInputOwnProps} from 'react-native-advanced-input-mask/lib/typescript/src/types'; -type InputType = 'markdown' | 'mask' | 'default'; +type InputType = 'markdown' | 'mask' | 'default' | 'dateSegments'; + +/** Everything the segmented date input needs, which reaches it through `BaseTextInput` rather than directly */ +type DateSegmentsConfig = { + /** The localized mask, such as YYYY-MM-DD, which decides the segment order, their widths and the separators */ + mask: string; + + getSegmentProps: UseDateSegmentInputResult['getSegmentProps']; + focusRequest: UseDateSegmentInputResult['focusRequest']; + onFieldBlur: UseDateSegmentInputResult['onFieldBlur']; +}; + type CustomBaseTextInputProps = ForwardedFSClassProps & WithSentryLabel & { label?: string; @@ -151,9 +164,12 @@ type CustomBaseTextInputProps = ForwardedFSClassProps & clearButtonIconSize?: number; contentWidth?: number; - /** The type (internal implementation) of input. Can be one of: `default`, `mask`, `markdown` */ + /** The type (internal implementation) of input. Can be one of: `default`, `mask`, `markdown`, `dateSegments` */ type?: InputType; + /** Required by the `dateSegments` type, and ignored by every other one */ + dateSegmentsConfig?: DateSegmentsConfig; + mask?: MaskedTextInputOwnProps['mask']; customNotations?: MaskedTextInputOwnProps['customNotations']; @@ -196,4 +212,4 @@ type BaseTextInputRef = HTMLFormElement | AnimatedTextInputRef; type BaseTextInputProps = CustomBaseTextInputProps & TextInputProps; -export type {BaseTextInputProps, BaseTextInputRef, InputType}; +export type {BaseTextInputProps, BaseTextInputRef, DateSegmentsConfig, InputType}; diff --git a/src/hooks/useDateSegmentInput.ts b/src/hooks/useDateSegmentInput.ts index eea4a2d705d3..1cdeff76fec0 100644 --- a/src/hooks/useDateSegmentInput.ts +++ b/src/hooks/useDateSegmentInput.ts @@ -1,16 +1,17 @@ /** - * Drives the guided date input, where the year, month and day are selected and edited one segment at a time. Every - * keystroke is handled here and the raw keystroke is prevented, so the field can only ever hold a date shaped value. + * Drives the guided date input, where the year, month and day are edited in one input each. Every keystroke is handled + * here and the raw keystroke is prevented, so a segment can only ever hold digits it is allowed to hold. + * + * Focus belongs to the browser. Each segment reports its own focus, and this hook only ever asks for a move when a + * keystroke calls for one, which is why nothing here has to arbitrate against a caret it does not control. */ import { DATE_SEGMENT_NAMES, EMPTY_SEGMENTS, getAdjacentSegmentName, - getCaretOffsetLimit, - getDateDisplay, getFirstUnfilledSegmentName, getISODateFromSegments, - getSegmentNameAtPosition, + getSegmentDisplay, getSegmentsFromISODate, getSegmentsFromText, getViewDateFromSegments, @@ -18,12 +19,12 @@ import { removeLastDigit, typeDigitIntoSegments, } from '@libs/DateInputMaskUtils'; -import type {DateSegmentName, DateSegmentRange, DateSegments} from '@libs/DateInputMaskUtils'; +import type {DateSegmentName, DateSegments} from '@libs/DateInputMaskUtils'; import {isNumeric} from '@libs/ValidationUtils'; -import type {TextInputKeyPressEvent, TextInputSelectionChangeEvent} from 'react-native'; +import type {TextInputKeyPressEvent} from 'react-native'; -import {useRef, useState} from 'react'; +import {useState} from 'react'; const FIRST_SEGMENT_NAME = DATE_SEGMENT_NAMES[0]; const LAST_SEGMENT_NAME = DATE_SEGMENT_NAMES[DATE_SEGMENT_NAMES.length - 1]; @@ -39,9 +40,6 @@ type UseDateSegmentInputParams = { /** The committed date in the format the app stores, shown whenever the field is not being edited */ value: string; - /** The localized mask, such as YYYY-MM-DD, which decides the segment order and the text shown for empty segments */ - mask: string; - /** Whether this platform lets the user type a date at all */ isEnabled: boolean; @@ -55,12 +53,29 @@ type UseDateSegmentInputParams = { onCommit: (isoDate: string) => void; }; +/** A request for a segment to take focus. The count is what carries it, so asking twice for the same segment works */ +type SegmentFocusRequest = { + name: DateSegmentName; + version: number; +}; + +/** Everything one segment's input needs. The segment itself is stateless and reports back through these */ +type DateSegmentProps = { + value: string; + onKeyPress: (event: TextInputKeyPressEvent) => void; + onChangeText: (text: string) => void; + onFocus: () => void; +}; + type UseDateSegmentInputResult = { - /** The text to render in the input */ + /** The committed date, shown while the field is not being edited */ displayValue: string; - /** The range covering the segment being edited, which selects it as a whole */ - selection: DateSegmentRange | undefined; + /** Whether the user is inside the field, so the segments rather than the committed date are what to render */ + isEditing: boolean; + + /** The segment the caller should move focus to, or undefined when no move has been asked for */ + focusRequest: SegmentFocusRequest | undefined; /** The month the calendar should show, so it follows the date being typed. Undefined leaves the calendar alone */ viewDate: Date | undefined; @@ -71,31 +86,29 @@ type UseDateSegmentInputResult = { /** Whether any digit has been typed, so the field is showing more than an untouched mask */ hasTypedDigits: boolean; - onKeyPress: (event: TextInputKeyPressEvent) => void; - onSelectionChange: (event: TextInputSelectionChangeEvent) => void; - onChangeText: (text: string) => void; - onFocus: () => void; - onBlur: () => void; + getSegmentProps: (name: DateSegmentName) => DateSegmentProps; + + /** Sends focus to the first segment still to be filled in, for a press that landed on the field but not on a segment */ + requestInitialFocus: () => void; + + /** Called once focus has left the field altogether rather than moved between segments */ + onFieldBlur: () => void; }; function isMoveKey(key: string): key is keyof typeof MOVE_KEYS { return key in MOVE_KEYS; } -export default function useDateSegmentInput({value, mask, isEnabled, minDate, maxDate, onCommit}: UseDateSegmentInputParams): UseDateSegmentInputResult { +export default function useDateSegmentInput({value, isEnabled, minDate, maxDate, onCommit}: UseDateSegmentInputParams): UseDateSegmentInputResult { // The segments only describe an edit in progress, so they are seeded on focus rather than synced with the value const [segments, setSegments] = useState(EMPTY_SEGMENTS); - const [activeSegmentName, setActiveSegmentName] = useState(FIRST_SEGMENT_NAME); - const [caretOffset, setCaretOffset] = useState(0); const [isEditing, setIsEditing] = useState(false); + const [focusRequest, setFocusRequest] = useState(undefined); // The month the calendar should show, which follows the typed date once the year is complete const [viewDate, setViewDate] = useState(undefined); const [viewDateVersion, setViewDateVersion] = useState(0); - // Re-rendering with a new value makes the browser report a caret of its own choosing. Honouring that would drag - // the active segment around, so the first report after a keystroke is discarded as an echo of our own update. - const hasPendingCaretEchoRef = useRef(false); - // Whether the next digit replaces the active segment instead of extending it, set on arriving at a segment - const shouldOverwriteRef = useRef(false); + // Whether the next digit replaces the segment instead of extending it, set on arriving at a segment + const [shouldOverwrite, setShouldOverwrite] = useState(false); const [appliedValue, setAppliedValue] = useState(value); // A date set from outside, by the calendar or by a restored draft, has to reach the segments as well. Without this @@ -108,26 +121,14 @@ export default function useDateSegmentInput({value, mask, isEnabled, minDate, ma } } - const {value: editingValue, ranges} = getDateDisplay(segments, mask); - const activeRange = ranges[activeSegmentName]; - // A caret parked on a digit place reads as three fields sharing one box. Selecting the whole segment instead would - // highlight it as a block, which is not what the design asks for. - const caretPosition = activeRange.start + caretOffset; - - const moveCaret = (name: DateSegmentName, offset: number, nextSegments: DateSegments = segments) => { - hasPendingCaretEchoRef.current = true; - setActiveSegmentName(name); - setCaretOffset(Math.min(Math.max(offset, 0), getCaretOffsetLimit(nextSegments, name))); + const requestFocus = (name: DateSegmentName) => { + setFocusRequest((previous) => ({name, version: (previous?.version ?? 0) + 1})); }; - /** - * Landing on a segment always rests the caret after whatever it already holds, so an empty one reads from its - * start and a filled one is ready to be typed over. `shouldOverwriteRef` is what makes that typing replace the - * segment rather than extend it. - */ - const enterSegment = (name: DateSegmentName, nextSegments: DateSegments = segments) => { - shouldOverwriteRef.current = true; - moveCaret(name, getCaretOffsetLimit(nextSegments, name), nextSegments); + /** Landing on a segment arms the overwrite, so the next digit replaces what is there rather than extending it */ + const enterSegment = (name: DateSegmentName) => { + setShouldOverwrite(true); + requestFocus(name); }; const commitIfComplete = (newSegments: DateSegments) => { @@ -162,43 +163,39 @@ export default function useDateSegmentInput({value, mask, isEnabled, minDate, ma commitIfComplete(newSegments); }; - const handleKeyPress = (event: TextInputKeyPressEvent) => { + const handleKeyPress = (name: DateSegmentName, event: TextInputKeyPressEvent) => { const key = event.nativeEvent.key; if (isNumeric(key)) { event.preventDefault(); - const result = typeDigitIntoSegments(segments, activeSegmentName, key, shouldOverwriteRef.current); - shouldOverwriteRef.current = false; + const result = typeDigitIntoSegments(segments, name, key, shouldOverwrite); + setShouldOverwrite(false); applySegments(result.segments); if (result.nextSegmentName) { - enterSegment(result.nextSegmentName, result.segments); - return; + enterSegment(result.nextSegmentName); } - - moveCaret(activeSegmentName, getCaretOffsetLimit(result.segments, activeSegmentName), result.segments); return; } if (isMoveKey(key)) { event.preventDefault(); - enterSegment(getAdjacentSegmentName(activeSegmentName, MOVE_KEYS[key])); + enterSegment(getAdjacentSegmentName(name, MOVE_KEYS[key])); return; } if (key === BACKSPACE_KEY || key === DELETE_KEY) { event.preventDefault(); - const trimmedSegments = removeLastDigit(segments, activeSegmentName); + const trimmedSegments = removeLastDigit(segments, name); // An empty segment has nothing to delete, so the keystroke falls back to leaving it if (!trimmedSegments) { - enterSegment(getAdjacentSegmentName(activeSegmentName, -1)); + enterSegment(getAdjacentSegmentName(name, -1)); return; } applySegments(trimmedSegments); - shouldOverwriteRef.current = false; - moveCaret(activeSegmentName, getCaretOffsetLimit(trimmedSegments, activeSegmentName), trimmedSegments); + setShouldOverwrite(false); return; } @@ -212,33 +209,7 @@ export default function useDateSegmentInput({value, mask, isEnabled, minDate, ma // A separator means the user is finished with this segment even if they only typed one digit into it event.preventDefault(); - enterSegment(getAdjacentSegmentName(activeSegmentName, 1)); - }; - - // Clicking into the text lands the caret anywhere, so snap it onto the digit place that was clicked - const handleSelectionChange = (event: TextInputSelectionChangeEvent) => { - if (hasPendingCaretEchoRef.current) { - hasPendingCaretEchoRef.current = false; - return; - } - - const position = event.nativeEvent.selection.start; - - // The caret is already here, so this is the browser reporting our own position back rather than the user - // aiming somewhere. Acting on it is what let a stray report drag the active segment around. - if (position === caretPosition) { - return; - } - - // Landing past the end of the text means the empty space in the field was clicked rather than a segment, so - // the first segment still to be filled in takes it, and a date with no gaps in it starts again from the year. - const clickedSegmentName = position >= editingValue.length ? (getFirstUnfilledSegmentName(segments) ?? FIRST_SEGMENT_NAME) : getSegmentNameAtPosition(position, ranges); - const clickedOffset = position - ranges[clickedSegmentName].start; - - // Clicking is aiming at a digit place rather than arriving at a segment, so the next digit extends what is - // there instead of replacing it - shouldOverwriteRef.current = false; - moveCaret(clickedSegmentName, clickedOffset); + enterSegment(getAdjacentSegmentName(name, 1)); }; // Every keystroke is prevented, so this only runs for text the user pasted in @@ -249,60 +220,65 @@ export default function useDateSegmentInput({value, mask, isEnabled, minDate, ma } applySegments(pastedSegments); - enterSegment(LAST_SEGMENT_NAME, pastedSegments); + enterSegment(LAST_SEGMENT_NAME); }; - const handleFocus = () => { - const seededSegments = getSegmentsFromISODate(value); - const firstSegmentName = getFirstUnfilledSegmentName(seededSegments) ?? FIRST_SEGMENT_NAME; + /** + * A segment reporting that it now holds focus, whether the user clicked it or a keystroke sent them there. Arriving + * at a segment always arms the overwrite, so the first digit replaces what is already in it. + */ + const handleSegmentFocus = () => { + setShouldOverwrite(true); + if (isEditing) { + return; + } + + const seededSegments = getSegmentsFromISODate(value); setSegments(seededSegments); assertViewDate(getViewDateFromSegments(seededSegments, new Date().getMonth(), minDate, maxDate)); - shouldOverwriteRef.current = false; - setActiveSegmentName(firstSegmentName); - setCaretOffset(getCaretOffsetLimit(seededSegments, firstSegmentName)); - - // Deliberately not arming the caret echo guard. The click that brought focus here reports its own position - // next, and that report is what moves the caret off the end of the text and onto a segment. setIsEditing(true); }; // An unfinished edit is dropped rather than cleared, so leaving the field restores the last committed date - const handleBlur = () => { + const handleFieldBlur = () => { setIsEditing(false); setSegments(EMPTY_SEGMENTS); - setCaretOffset(0); setViewDate(undefined); - shouldOverwriteRef.current = false; + setFocusRequest(undefined); + setShouldOverwrite(false); }; if (!isEnabled) { return { displayValue: value, - selection: undefined, + isEditing: false, + focusRequest: undefined, viewDate: undefined, viewDateVersion: 0, hasTypedDigits: false, - onKeyPress: () => {}, - onSelectionChange: () => {}, - onChangeText: () => {}, - onFocus: () => {}, - onBlur: () => {}, + getSegmentProps: () => ({value: '', onKeyPress: () => {}, onChangeText: () => {}, onFocus: () => {}}), + requestInitialFocus: () => {}, + onFieldBlur: () => {}, }; } return { - displayValue: isEditing ? editingValue : value, - selection: isEditing ? {start: caretPosition, end: caretPosition} : undefined, + displayValue: value, + isEditing, + focusRequest, viewDate: isEditing ? viewDate : undefined, viewDateVersion, hasTypedDigits: isEditing && hasAnySegment(segments), - onKeyPress: handleKeyPress, - onSelectionChange: handleSelectionChange, - onChangeText: handleChangeText, - onFocus: handleFocus, - onBlur: handleBlur, + getSegmentProps: (name: DateSegmentName) => ({ + value: getSegmentDisplay(segments, name), + onKeyPress: (event: TextInputKeyPressEvent) => handleKeyPress(name, event), + onChangeText: handleChangeText, + onFocus: handleSegmentFocus, + }), + requestInitialFocus: () => enterSegment(getFirstUnfilledSegmentName(getSegmentsFromISODate(value)) ?? FIRST_SEGMENT_NAME), + onFieldBlur: handleFieldBlur, }; } -export type {UseDateSegmentInputParams, UseDateSegmentInputResult}; +export type {DateSegmentProps, SegmentFocusRequest, UseDateSegmentInputParams, UseDateSegmentInputResult}; diff --git a/src/languages/de.ts b/src/languages/de.ts index c3f2566239ed..c82e34ac4129 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -35,6 +35,11 @@ type States = Record; type AllCountries = Record; const translations: TranslationDeepObject = { common: { + dateSegments: { + year: 'Jahr', + month: 'Monat', + day: 'Tag', + }, durationDays: ({count}: {count: number}) => ({ one: `1 Tag`, other: `${count} Tage`, diff --git a/src/languages/el.ts b/src/languages/el.ts index 8f7c78a93910..29a12eb1db6e 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -32,6 +32,11 @@ type States = Record; type AllCountries = Record; const translations: TranslationDeepObject = { common: { + dateSegments: { + year: 'Έτος', + month: 'Μήνας', + day: 'Ημέρα', + }, durationDays: ({count}: {count: number}) => ({ one: `1 ημέρα`, other: `${count} ημέρες`, diff --git a/src/languages/en.ts b/src/languages/en.ts index 0ab827f6b67a..967b4d4fd954 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -534,6 +534,11 @@ const translations = { reimbursableTotal: 'Reimbursable total', nonReimbursableTotal: 'Non-reimbursable total', opensInNewTab: 'Opens in a new tab', + dateSegments: { + year: 'Year', + month: 'Month', + day: 'Day', + }, locked: 'Locked', month: 'Month', week: 'Week', diff --git a/src/languages/es.ts b/src/languages/es.ts index 4eaad6f99764..89e6c823e1b2 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -480,6 +480,11 @@ const translations: TranslationDeepObject = { amountDebited: 'Importe debitado', amountReimbursed: 'Importe reembolsado', opensInNewTab: 'Se abre en una nueva pestaña', + dateSegments: { + year: 'Año', + month: 'Mes', + day: 'Día', + }, locked: 'Bloqueado', month: 'Monat', week: 'Semana', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index cb157ffffb4c..2e1c6656792e 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -35,6 +35,11 @@ type States = Record; type AllCountries = Record; const translations: TranslationDeepObject = { common: { + dateSegments: { + year: 'Année', + month: 'Mois', + day: 'Jour', + }, durationDays: ({count}: {count: number}) => ({ one: `${count} jour`, other: `${count} jours`, diff --git a/src/languages/it.ts b/src/languages/it.ts index 8276553f8ee2..6bc6033ef964 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -35,6 +35,11 @@ type States = Record; type AllCountries = Record; const translations: TranslationDeepObject = { common: { + dateSegments: { + year: 'Anno', + month: 'Mese', + day: 'Giorno', + }, durationDays: ({count}: {count: number}) => ({ one: `1 giorno`, other: `${count} giorni`, diff --git a/src/languages/ja.ts b/src/languages/ja.ts index a00d7c5b0eb8..12eef2035951 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -35,6 +35,11 @@ type States = Record; type AllCountries = Record; const translations: TranslationDeepObject = { common: { + dateSegments: { + year: '年', + month: '月', + day: '日', + }, durationDays: ({count}: {count: number}) => ({ one: `1 日`, other: `${count} 日`, diff --git a/src/languages/nl.ts b/src/languages/nl.ts index c30e3f205c6d..25d839f1b6e8 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -35,6 +35,11 @@ type States = Record; type AllCountries = Record; const translations: TranslationDeepObject = { common: { + dateSegments: { + year: 'Jaar', + month: 'Maand', + day: 'Dag', + }, durationDays: ({count}: {count: number}) => ({ one: `1 dag`, other: `${count} dagen`, diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 885864be4592..337ffe6c5c09 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -35,6 +35,11 @@ type States = Record; type AllCountries = Record; const translations: TranslationDeepObject = { common: { + dateSegments: { + year: 'Rok', + month: 'Miesiąc', + day: 'Dzień', + }, durationDays: ({count}: {count: number}) => ({ one: `1 dzień`, other: `${count} dni`, diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 3a44db440220..f97d4b93c41f 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -35,6 +35,11 @@ type States = Record; type AllCountries = Record; const translations: TranslationDeepObject = { common: { + dateSegments: { + year: 'Ano', + month: 'Mês', + day: 'Dia', + }, durationDays: ({count}: {count: number}) => ({ one: `${count} dia`, other: `${count} dias`, diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 0519fa4dfff9..0f5809bcf94b 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -35,6 +35,11 @@ type States = Record; type AllCountries = Record; const translations: TranslationDeepObject = { common: { + dateSegments: { + year: '年', + month: '月', + day: '日', + }, durationDays: ({count}: {count: number}) => ({ one: `1 天`, other: `${count} 天`, diff --git a/src/libs/DateInputMaskUtils.ts b/src/libs/DateInputMaskUtils.ts index 78e68395617e..ff04ce94ec0d 100644 --- a/src/libs/DateInputMaskUtils.ts +++ b/src/libs/DateInputMaskUtils.ts @@ -36,11 +36,6 @@ type DateSegmentName = TupleToUnion; /** The digits typed into each segment, empty when the segment has not been filled in yet */ type DateSegments = Record; -type DateSegmentRange = { - start: number; - end: number; -}; - type DateMaskPart = { name: DateSegmentName; @@ -51,14 +46,6 @@ type DateMaskPart = { separator: string; }; -type DateDisplay = { - /** The text to show in the input */ - value: string; - - /** Where each segment sits inside that text, so a segment can be selected as a whole */ - ranges: Record; -}; - const EMPTY_SEGMENTS: DateSegments = {year: '', month: '', day: ''}; function isMaskLetter(character: string): boolean { @@ -109,43 +96,17 @@ function isZeroPaddedSegment(name: DateSegmentName): name is keyof typeof SEGMEN } /** - * How far into a segment the caret may sit. A zero padded segment displays its digits at the end, so one typed digit - * puts the caret at the end of the segment rather than one place into it. + * The text one segment shows. A zero padded segment fills from the right, so a day part way through reads as 02 and + * becomes 23 on the next digit. An empty segment renders nothing and lets its own placeholder show through. */ -function getCaretOffsetLimit(segments: DateSegments, name: DateSegmentName): number { - const typedLength = segments[name].length; +function getSegmentDisplay(segments: DateSegments, name: DateSegmentName): string { + const digits = segments[name].slice(0, getSegmentLength(name)); - if (!typedLength) { - return 0; + if (!digits) { + return ''; } - return isZeroPaddedSegment(name) ? getSegmentLength(name) : Math.min(typedLength, getSegmentLength(name)); -} - -function getDateDisplay(segments: DateSegments, mask: string): DateDisplay { - let value = ''; - const ranges: Record = {year: {start: 0, end: 0}, month: {start: 0, end: 0}, day: {start: 0, end: 0}}; - - for (const part of getDateMaskParts(mask)) { - // Typed digits take the place of the mask letters, so a half typed year reads as 2YYY rather than 2. A zero - // padded segment fills from the right instead, so a day part way through reads as 02 and becomes 23 on the - // next digit. Either way the segment keeps the width of its mask, which is what lets a caret position mean - // the same thing twice. - const digits = segments[part.name].slice(0, part.placeholder.length); - const text = digits && isZeroPaddedSegment(part.name) ? digits.padStart(part.placeholder.length, '0') : `${digits}${part.placeholder.slice(digits.length)}`; - - ranges[part.name] = {start: value.length, end: value.length + text.length}; - value += `${text}${part.separator}`; - } - - return {value, ranges}; -} - -/** Which segment a caret position falls in, so clicking into the text selects the segment that was clicked */ -function getSegmentNameAtPosition(position: number, ranges: Record): DateSegmentName { - const name = DATE_SEGMENT_NAMES.find((segmentName) => position <= ranges[segmentName].end); - - return name ?? DATE_SEGMENT_NAMES[DATE_SEGMENT_NAMES.length - 1]; + return isZeroPaddedSegment(name) ? digits.padStart(getSegmentLength(name), '0') : digits; } /** The segment that follows this one, or undefined for the last one, which has nowhere to hand a finished value on to */ @@ -331,11 +292,11 @@ export { DATE_SEGMENT_NAMES, EMPTY_SEGMENTS, getAdjacentSegmentName, - getCaretOffsetLimit, - getDateDisplay, + getDateMaskParts, getFirstUnfilledSegmentName, getISODateFromSegments, - getSegmentNameAtPosition, + getSegmentDisplay, + getSegmentLength, getSegmentsFromISODate, getSegmentsFromText, getViewDateFromSegments, @@ -343,4 +304,4 @@ export { removeLastDigit, typeDigitIntoSegments, }; -export type {DateSegmentName, DateSegmentRange, DateSegments}; +export type {DateMaskPart, DateSegmentName, DateSegments}; diff --git a/tests/unit/DateInputMaskUtilsTest.ts b/tests/unit/DateInputMaskUtilsTest.ts index 8c22ddc94840..0014601659c9 100644 --- a/tests/unit/DateInputMaskUtilsTest.ts +++ b/tests/unit/DateInputMaskUtilsTest.ts @@ -1,10 +1,10 @@ import { getAdjacentSegmentName, - getCaretOffsetLimit, - getDateDisplay, + getDateMaskParts, getFirstUnfilledSegmentName, getISODateFromSegments, - getSegmentNameAtPosition, + getSegmentDisplay, + getSegmentLength, getSegmentsFromISODate, getSegmentsFromText, getViewDateFromSegments, @@ -121,77 +121,49 @@ describe('DateInputMaskUtils', () => { }); }); - describe('getDateDisplay', () => { - it('shows the mask while every segment is empty', () => { - expect(getDateDisplay(EMPTY, MASK).value).toBe(MASK); + describe('getSegmentDisplay', () => { + it('shows nothing for an empty segment, leaving its own placeholder to show through', () => { + expect(getSegmentDisplay(EMPTY, 'year')).toBe(''); + expect(getSegmentDisplay(EMPTY, 'month')).toBe(''); }); - it('keeps the mask for the segments still to be filled in', () => { - expect(getDateDisplay(segments('2026', '', ''), MASK).value).toBe('2026-MM-DD'); - expect(getDateDisplay(segments('2026', '09', ''), MASK).value).toBe('2026-09-DD'); - expect(getDateDisplay(segments('2026', '09', '18'), MASK).value).toBe('2026-09-18'); - }); - - it('keeps the mask letters of the digit places a half typed year has not reached', () => { - expect(getDateDisplay(segments('2', '', ''), MASK).value).toBe('2YYY-MM-DD'); - expect(getDateDisplay(segments('20', '', ''), MASK).value).toBe('20YY-MM-DD'); + it('shows the year as far as it has been typed', () => { + expect(getSegmentDisplay(segments('2', '', ''), 'year')).toBe('2'); + expect(getSegmentDisplay(segments('20', '', ''), 'year')).toBe('20'); + expect(getSegmentDisplay(segments('2026', '', ''), 'year')).toBe('2026'); }); it('zero pads a half typed month or day, which fill from the right', () => { - expect(getDateDisplay(segments('2026', '1', ''), MASK).value).toBe('2026-01-DD'); - expect(getDateDisplay(segments('2026', '09', '2'), MASK).value).toBe('2026-09-02'); - }); - - it('reports where each segment sits in the text', () => { - expect(getDateDisplay(segments('2026', '09', '18'), MASK).ranges).toEqual({ - year: {start: 0, end: 4}, - month: {start: 5, end: 7}, - day: {start: 8, end: 10}, - }); - }); - - it('holds the ranges still while a segment is half typed, so a caret position keeps its meaning', () => { - const {ranges} = getDateDisplay(segments('2026', '1', ''), MASK); - - expect(ranges.month).toEqual({start: 5, end: 7}); - expect(ranges.day).toEqual({start: 8, end: 10}); + expect(getSegmentDisplay(segments('2026', '1', ''), 'month')).toBe('01'); + expect(getSegmentDisplay(segments('2026', '09', '2'), 'day')).toBe('02'); }); - it('uses the letters and separators of the localized mask', () => { - expect(getDateDisplay(segments('2026', '', ''), 'AAAA-MM-JJ').value).toBe('2026-MM-JJ'); + it('shows a finished month or day as typed', () => { + expect(getSegmentDisplay(segments('2026', '09', '18'), 'month')).toBe('09'); + expect(getSegmentDisplay(segments('2026', '09', '18'), 'day')).toBe('18'); }); }); - describe('getCaretOffsetLimit', () => { - it('keeps the caret at the start of an empty segment', () => { - expect(getCaretOffsetLimit(EMPTY, 'year')).toBe(0); - expect(getCaretOffsetLimit(EMPTY, 'day')).toBe(0); - }); - - it('follows the typed digits through the year, which fills from the left', () => { - expect(getCaretOffsetLimit(segments('2', '', ''), 'year')).toBe(1); - expect(getCaretOffsetLimit(segments('202', '', ''), 'year')).toBe(3); - expect(getCaretOffsetLimit(segments('2026', '', ''), 'year')).toBe(4); + describe('getDateMaskParts', () => { + it('reads the segment order, placeholders and separators out of the mask', () => { + expect(getDateMaskParts(MASK)).toEqual([ + {name: 'year', placeholder: 'YYYY', separator: '-'}, + {name: 'month', placeholder: 'MM', separator: '-'}, + {name: 'day', placeholder: 'DD', separator: ''}, + ]); }); - it('rests at the end of a zero padded segment, since 02 shows the 2 last', () => { - expect(getCaretOffsetLimit(segments('2026', '1', ''), 'month')).toBe(2); - expect(getCaretOffsetLimit(segments('2026', '12', ''), 'month')).toBe(2); + it('uses the letters and separators of the localized mask', () => { + expect(getDateMaskParts('AAAA/MM/JJ').map((part) => part.placeholder)).toEqual(['AAAA', 'MM', 'JJ']); + expect(getDateMaskParts('AAAA/MM/JJ').map((part) => part.separator)).toEqual(['/', '/', '']); }); }); - describe('getSegmentNameAtPosition', () => { - const {ranges} = getDateDisplay(segments('2026', '09', '18'), MASK); - - it('maps a position to the segment that covers it', () => { - expect(getSegmentNameAtPosition(0, ranges)).toBe('year'); - expect(getSegmentNameAtPosition(4, ranges)).toBe('year'); - expect(getSegmentNameAtPosition(6, ranges)).toBe('month'); - expect(getSegmentNameAtPosition(9, ranges)).toBe('day'); - }); - - it('falls back to the last segment past the end of the text', () => { - expect(getSegmentNameAtPosition(99, ranges)).toBe('day'); + describe('getSegmentLength', () => { + it('gives the year twice the digits of the others', () => { + expect(getSegmentLength('year')).toBe(4); + expect(getSegmentLength('month')).toBe(2); + expect(getSegmentLength('day')).toBe(2); }); }); From 8f9b1521131400882891e5b0aa7764f313fac779 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 18 Sep 2026 23:13:49 +0530 Subject: [PATCH 15/23] fix: measure each date segment instead of estimating its width Signed-off-by: krishna2323 --- src/components/DateSegmentsInput.tsx | 36 ++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/src/components/DateSegmentsInput.tsx b/src/components/DateSegmentsInput.tsx index 6c87251070eb..65130f590c39 100644 --- a/src/components/DateSegmentsInput.tsx +++ b/src/components/DateSegmentsInput.tsx @@ -14,7 +14,7 @@ import type {DateSegmentName} from '@libs/DateInputMaskUtils'; import CONST from '@src/CONST'; -import React, {useEffect, useRef} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import type {AnimatedTextInputRef} from './RNTextInput'; @@ -23,14 +23,21 @@ import type {BaseTextInputProps} from './TextInput/BaseTextInput/types'; import RNTextInput from './RNTextInput'; import Text from './Text'; -/** The mask letters are wider than the digits that replace them, so a segment is sized to hold its placeholder */ -const SEGMENT_PADDING = CONST.CHARACTER_WIDTH / 2; +/** Leaves the caret somewhere to sit, since a segment sized to its text exactly would clip it at the end */ +const CARET_ALLOWANCE = 2; + +/** Used until the real text has been measured, so the first paint is close rather than collapsed */ +const ESTIMATED_CHARACTER_WIDTH = CONST.CHARACTER_WIDTH; /** - * The style the field hands down stretches its input to fill the row, which would spread three of them evenly across - * it. Each segment is sized to its own digits instead, so the date reads as one run of text. + * The style the field hands down stretches its input across the whole row, which would spread the segments evenly and + * make each separator as wide as the field. Everything here is sized to its own text instead, so the date reads as one + * run of text. */ -const SIZED_TO_CONTENT = {flexGrow: 0, flexShrink: 0, flexBasis: 'auto'} as const; +const SIZED_TO_CONTENT = {flexGrow: 0, flexShrink: 0, flexBasis: 'auto', width: 'auto'} as const; + +/** Holds a copy of a segment's text purely to be measured, so it must not take part in the layout it is measuring */ +const MEASURED_OFF_LAYOUT = {position: 'absolute', opacity: 0} as const; function DateSegmentsInput({dateSegmentsConfig, style, placeholderTextColor, disabled, onPressOut, forwardedFSClass, ref}: BaseTextInputProps) { const styles = useThemeStyles(); @@ -40,6 +47,9 @@ function DateSegmentsInput({dateSegmentsConfig, style, placeholderTextColor, dis // moving within it once the next focus has had its chance to arrive. const blurTimeoutRef = useRef(undefined); const appliedFocusVersionRef = useRef(0); + // The mask letters are wider than the digits that replace them, so a fixed width would either clip the placeholder + // or leave a gap once the segment is filled in. Each one is measured from the text it is currently showing. + const [measuredWidths, setMeasuredWidths] = useState>>({}); const focusRequest = dateSegmentsConfig?.focusRequest; @@ -69,9 +79,21 @@ function DateSegmentsInput({dateSegmentsConfig, style, placeholderTextColor, dis {getDateMaskParts(mask).map((part) => { const segmentProps = getSegmentProps(part.name); + const measuredText = segmentProps.value || part.placeholder; + const width = (measuredWidths[part.name] ?? measuredText.length * ESTIMATED_CHARACTER_WIDTH) + CARET_ALLOWANCE; return ( + { + const layoutWidth = event.nativeEvent.layout.width; + + setMeasuredWidths((previous) => (previous[part.name] === layoutWidth ? previous : {...previous, [part.name]: layoutWidth})); + }} + > + {measuredText} + { segmentRefs.current[part.name] = element; @@ -87,7 +109,7 @@ function DateSegmentsInput({dateSegmentsConfig, style, placeholderTextColor, dis ref.current = element; } }} - style={[style, styles.p0, SIZED_TO_CONTENT, {width: part.placeholder.length * CONST.CHARACTER_WIDTH + SEGMENT_PADDING}]} + style={[style, styles.p0, SIZED_TO_CONTENT, {width}]} value={segmentProps.value} placeholder={part.placeholder} placeholderTextColor={placeholderTextColor} From 1f44f08ade38a55635fb2323ef1f401417650c77 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 18 Sep 2026 23:19:43 +0530 Subject: [PATCH 16/23] fix: show the committed date when the date field is not being edited Signed-off-by: krishna2323 --- src/components/DateSegmentsInput.tsx | 18 +++++++++++++----- src/hooks/useDateSegmentInput.ts | 5 ++++- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/components/DateSegmentsInput.tsx b/src/components/DateSegmentsInput.tsx index 65130f590c39..f615e5076814 100644 --- a/src/components/DateSegmentsInput.tsx +++ b/src/components/DateSegmentsInput.tsx @@ -24,7 +24,13 @@ import RNTextInput from './RNTextInput'; import Text from './Text'; /** Leaves the caret somewhere to sit, since a segment sized to its text exactly would clip it at the end */ -const CARET_ALLOWANCE = 2; +const CARET_ALLOWANCE = 1; + +/** + * Only the horizontal padding is dropped. The vertical padding that the field hands down is what sits the text below + * the floating label, so the segments keep it and stay in line with any other text field. + */ +const NO_HORIZONTAL_PADDING = {paddingHorizontal: 0} as const; /** Used until the real text has been measured, so the first paint is close rather than collapsed */ const ESTIMATED_CHARACTER_WIDTH = CONST.CHARACTER_WIDTH; @@ -75,8 +81,10 @@ function DateSegmentsInput({dateSegmentsConfig, style, placeholderTextColor, dis blurTimeoutRef.current = setTimeout(onFieldBlur, 0); }; + // The segments stretch down the row as a single input would, so the padding they inherit lands their text in the + // same place as any other text field's return ( - + {getDateMaskParts(mask).map((part) => { const segmentProps = getSegmentProps(part.name); const measuredText = segmentProps.value || part.placeholder; @@ -85,7 +93,7 @@ function DateSegmentsInput({dateSegmentsConfig, style, placeholderTextColor, dis return ( { const layoutWidth = event.nativeEvent.layout.width; @@ -109,7 +117,7 @@ function DateSegmentsInput({dateSegmentsConfig, style, placeholderTextColor, dis ref.current = element; } }} - style={[style, styles.p0, SIZED_TO_CONTENT, {width}]} + style={[style, NO_HORIZONTAL_PADDING, SIZED_TO_CONTENT, {width}]} value={segmentProps.value} placeholder={part.placeholder} placeholderTextColor={placeholderTextColor} @@ -126,7 +134,7 @@ function DateSegmentsInput({dateSegmentsConfig, style, placeholderTextColor, dis disabled={disabled} forwardedFSClass={forwardedFSClass} /> - {!!part.separator && {part.separator}} + {!!part.separator && {part.separator}} ); })} diff --git a/src/hooks/useDateSegmentInput.ts b/src/hooks/useDateSegmentInput.ts index 1cdeff76fec0..f6801ab0012d 100644 --- a/src/hooks/useDateSegmentInput.ts +++ b/src/hooks/useDateSegmentInput.ts @@ -263,6 +263,9 @@ export default function useDateSegmentInput({value, isEnabled, minDate, maxDate, }; } + // The segments describe an edit in progress, so outside of one the committed date is what the field has to show + const displayedSegments = isEditing ? segments : getSegmentsFromISODate(value); + return { displayValue: value, isEditing, @@ -271,7 +274,7 @@ export default function useDateSegmentInput({value, isEnabled, minDate, maxDate, viewDateVersion, hasTypedDigits: isEditing && hasAnySegment(segments), getSegmentProps: (name: DateSegmentName) => ({ - value: getSegmentDisplay(segments, name), + value: getSegmentDisplay(displayedSegments, name), onKeyPress: (event: TextInputKeyPressEvent) => handleKeyPress(name, event), onChangeText: handleChangeText, onFocus: handleSegmentFocus, From d3068c33f94adeb9fbc254e0f95b02179a76b0d2 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 18 Sep 2026 23:23:18 +0530 Subject: [PATCH 17/23] fix: let a press land on the date segment it was aimed at Signed-off-by: krishna2323 --- src/components/DatePicker/index.tsx | 8 ++------ src/components/DateSegmentsInput.tsx | 12 +++++++++++- src/components/TextInput/BaseTextInput/types.ts | 1 + 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/components/DatePicker/index.tsx b/src/components/DatePicker/index.tsx index 355df62ef44f..7a1779318646 100644 --- a/src/components/DatePicker/index.tsx +++ b/src/components/DatePicker/index.tsx @@ -190,14 +190,9 @@ function DatePicker({ event.preventDefault(); } - // A press that landed on the field but not on a segment leaves nothing focused, so send it somewhere useful - if (shouldAllowTyping && !segmentInput.isEditing) { - segmentInput.requestInitialFocus(); - } - showDatePickerModal(); }, - [shouldAllowTyping, showDatePickerModal, segmentInput], + [shouldAllowTyping, showDatePickerModal], ); // Reaching the field by keyboard never fires a press, so focus is what opens the calendar once typing is allowed. @@ -290,6 +285,7 @@ function DatePicker({ mask: dateMask, getSegmentProps: segmentInput.getSegmentProps, focusRequest: segmentInput.focusRequest, + requestInitialFocus: segmentInput.requestInitialFocus, onFieldBlur: segmentInput.onFieldBlur, } : undefined diff --git a/src/components/DateSegmentsInput.tsx b/src/components/DateSegmentsInput.tsx index f615e5076814..81093928c3ff 100644 --- a/src/components/DateSegmentsInput.tsx +++ b/src/components/DateSegmentsInput.tsx @@ -20,6 +20,7 @@ import {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'; @@ -74,7 +75,7 @@ function DateSegmentsInput({dateSegmentsConfig, style, placeholderTextColor, dis return null; } - const {mask, getSegmentProps, onFieldBlur} = dateSegmentsConfig; + const {mask, getSegmentProps, requestInitialFocus, onFieldBlur} = dateSegmentsConfig; const handleSegmentBlur = () => { clearTimeout(blurTimeoutRef.current); @@ -138,6 +139,15 @@ function DateSegmentsInput({dateSegmentsConfig, style, placeholderTextColor, dis ); })} + {/* A single input filled the row, so clicking anywhere in the field focused it. This takes the space left + over after the day, so pressing it still lands somewhere useful rather than doing nothing. */} + ); } diff --git a/src/components/TextInput/BaseTextInput/types.ts b/src/components/TextInput/BaseTextInput/types.ts index 5a618c50b0e2..b8c5d5223e92 100644 --- a/src/components/TextInput/BaseTextInput/types.ts +++ b/src/components/TextInput/BaseTextInput/types.ts @@ -22,6 +22,7 @@ type DateSegmentsConfig = { getSegmentProps: UseDateSegmentInputResult['getSegmentProps']; focusRequest: UseDateSegmentInputResult['focusRequest']; + requestInitialFocus: UseDateSegmentInputResult['requestInitialFocus']; onFieldBlur: UseDateSegmentInputResult['onFieldBlur']; }; From 1d90b469ace89603276de9afea09ec7578807a87 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 18 Sep 2026 23:29:35 +0530 Subject: [PATCH 18/23] fix: stop the date field's props being dropped by the segmented input Signed-off-by: krishna2323 --- src/components/DatePicker/index.tsx | 7 ++--- src/components/DateSegmentsInput.tsx | 39 ++++++++++++++++++++++++---- 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/src/components/DatePicker/index.tsx b/src/components/DatePicker/index.tsx index 7a1779318646..c0aed93b491d 100644 --- a/src/components/DatePicker/index.tsx +++ b/src/components/DatePicker/index.tsx @@ -185,14 +185,15 @@ function DatePicker({ const handlePress = useCallback>( (event) => { - // Preventing the press would also stop focus from landing in the segment the user clicked. - if (!shouldAllowTyping && 'preventDefault' in 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(); }, - [shouldAllowTyping, showDatePickerModal], + [showDatePickerModal], ); // Reaching the field by keyboard never fires a press, so focus is what opens the calendar once typing is allowed. diff --git a/src/components/DateSegmentsInput.tsx b/src/components/DateSegmentsInput.tsx index 81093928c3ff..ef6dc93b9ecf 100644 --- a/src/components/DateSegmentsInput.tsx +++ b/src/components/DateSegmentsInput.tsx @@ -46,7 +46,21 @@ const SIZED_TO_CONTENT = {flexGrow: 0, flexShrink: 0, flexBasis: 'auto', width: /** Holds a copy of a segment's text purely to be measured, so it must not take part in the layout it is measuring */ const MEASURED_OFF_LAYOUT = {position: 'absolute', opacity: 0} as const; -function DateSegmentsInput({dateSegmentsConfig, style, placeholderTextColor, disabled, onPressOut, forwardedFSClass, ref}: BaseTextInputProps) { +function DateSegmentsInput({ + dateSegmentsConfig, + style, + placeholderTextColor, + disabled, + onPressOut, + onFocus, + onBlur, + readOnly, + forwardedFSClass, + accessibilityLabel, + 'aria-describedby': ariaDescribedBy, + 'aria-invalid': ariaInvalid, + ref, +}: BaseTextInputProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const segmentRefs = useRef>>({}); @@ -77,15 +91,26 @@ function DateSegmentsInput({dateSegmentsConfig, style, placeholderTextColor, dis const {mask, getSegmentProps, requestInitialFocus, onFieldBlur} = dateSegmentsConfig; - const handleSegmentBlur = () => { + /** + * The field's own blur handler is what a form hangs its validation on, so it has to wait for the whole field to be + * left rather than run on every hop between segments. + */ + const handleSegmentBlur: NonNullable = (event) => { clearTimeout(blurTimeoutRef.current); - blurTimeoutRef.current = setTimeout(onFieldBlur, 0); + blurTimeoutRef.current = setTimeout(() => { + onFieldBlur(); + onBlur?.(event); + }, 0); }; // The segments stretch down the row as a single input would, so the padding they inherit lands their text in the // same place as any other text field's return ( - + {getDateMaskParts(mask).map((part) => { const segmentProps = getSegmentProps(part.name); const measuredText = segmentProps.value || part.placeholder; @@ -124,16 +149,20 @@ function DateSegmentsInput({dateSegmentsConfig, style, placeholderTextColor, dis placeholderTextColor={placeholderTextColor} onKeyPress={segmentProps.onKeyPress} onChangeText={segmentProps.onChangeText} - onFocus={() => { + onFocus={(event) => { clearTimeout(blurTimeoutRef.current); segmentProps.onFocus(); + onFocus?.(event); }} onBlur={handleSegmentBlur} onPressOut={onPressOut} accessibilityLabel={translate(`common.dateSegments.${part.name}`)} inputMode="numeric" disabled={disabled} + readOnly={readOnly} forwardedFSClass={forwardedFSClass} + aria-describedby={ariaDescribedBy} + aria-invalid={ariaInvalid} /> {!!part.separator && {part.separator}} From 996c988142ce65a9048ec6bcf43e4cea10108961 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 18 Sep 2026 23:37:30 +0530 Subject: [PATCH 19/23] fix: land the caret at the end of a date segment reached by keystroke Signed-off-by: krishna2323 --- src/components/DateSegmentsInput.tsx | 11 +++++++++-- src/hooks/useDateSegmentInput.ts | 4 ++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/components/DateSegmentsInput.tsx b/src/components/DateSegmentsInput.tsx index ef6dc93b9ecf..34907535cb5a 100644 --- a/src/components/DateSegmentsInput.tsx +++ b/src/components/DateSegmentsInput.tsx @@ -80,7 +80,14 @@ function DateSegmentsInput({ } appliedFocusVersionRef.current = focusRequest.version; - segmentRefs.current[focusRequest.name]?.focus(); + + const element = segmentRefs.current[focusRequest.name]; + element?.focus(); + + // Arriving by keystroke rests the caret after the digits already there, rather than wherever it was last left + // in this segment, so a half typed month reads as 02 and not 0 followed by a caret and a 2. + const caretPosition = element?.value?.length ?? 0; + element?.setSelectionRange?.(caretPosition, caretPosition); }, [focusRequest]); useEffect(() => () => clearTimeout(blurTimeoutRef.current), []); @@ -175,7 +182,7 @@ function DateSegmentsInput({ accessibilityLabel={translate('common.date')} onPress={requestInitialFocus} sentryLabel="DateSegmentsInput-EmptySpace" - style={styles.flex1} + style={[styles.flex1, styles.cursorText]} /> ); diff --git a/src/hooks/useDateSegmentInput.ts b/src/hooks/useDateSegmentInput.ts index f6801ab0012d..89b2bb28b172 100644 --- a/src/hooks/useDateSegmentInput.ts +++ b/src/hooks/useDateSegmentInput.ts @@ -26,7 +26,6 @@ import type {TextInputKeyPressEvent} from 'react-native'; import {useState} from 'react'; -const FIRST_SEGMENT_NAME = DATE_SEGMENT_NAMES[0]; const LAST_SEGMENT_NAME = DATE_SEGMENT_NAMES[DATE_SEGMENT_NAMES.length - 1]; const BACKSPACE_KEY = 'Backspace'; @@ -279,7 +278,8 @@ export default function useDateSegmentInput({value, isEnabled, minDate, maxDate, onChangeText: handleChangeText, onFocus: handleSegmentFocus, }), - requestInitialFocus: () => enterSegment(getFirstUnfilledSegmentName(getSegmentsFromISODate(value)) ?? FIRST_SEGMENT_NAME), + // A date with no gaps in it has no segment waiting to be filled, so the last one takes the focus + requestInitialFocus: () => enterSegment(getFirstUnfilledSegmentName(displayedSegments) ?? LAST_SEGMENT_NAME), onFieldBlur: handleFieldBlur, }; } From 62993b779e4fcf0bf5b1b9d971f35e66f78b6506 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 18 Sep 2026 23:46:30 +0530 Subject: [PATCH 20/23] fix: reposition the calendar when the date field changes height Signed-off-by: krishna2323 --- src/components/DatePicker/index.tsx | 12 +++++- src/components/DateSegmentsInput.tsx | 40 ++++++++++++++----- .../TextInput/BaseTextInput/types.ts | 1 - src/hooks/useDateSegmentInput.ts | 7 ---- 4 files changed, 41 insertions(+), 19 deletions(-) diff --git a/src/components/DatePicker/index.tsx b/src/components/DatePicker/index.tsx index c0aed93b491d..820884080a58 100644 --- a/src/components/DatePicker/index.tsx +++ b/src/components/DatePicker/index.tsx @@ -225,6 +225,16 @@ function DatePicker({ // 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 handleAnchorLayout = () => { + if (!isModalVisible) { + return; + } + + calculatePopoverPosition(); + }; + const handleClear = () => { onTouched?.(); onInputChange?.(''); @@ -268,6 +278,7 @@ function DatePicker({ { + const element = segmentRefs.current[name]; + element?.focus(); + + const caretPosition = element?.value?.length ?? 0; + element?.setSelectionRange?.(caretPosition, caretPosition); + }; + useEffect(() => { if (!focusRequest || focusRequest.version === appliedFocusVersionRef.current) { return; } appliedFocusVersionRef.current = focusRequest.version; - - const element = segmentRefs.current[focusRequest.name]; - element?.focus(); - - // Arriving by keystroke rests the caret after the digits already there, rather than wherever it was last left - // in this segment, so a half typed month reads as 02 and not 0 followed by a caret and a 2. - const caretPosition = element?.value?.length ?? 0; - element?.setSelectionRange?.(caretPosition, caretPosition); + focusSegment(focusRequest.name); }, [focusRequest]); useEffect(() => () => clearTimeout(blurTimeoutRef.current), []); @@ -96,7 +101,22 @@ function DateSegmentsInput({ return null; } - const {mask, getSegmentProps, requestInitialFocus, onFieldBlur} = dateSegmentsConfig; + const {mask, getSegmentProps, onFieldBlur} = dateSegmentsConfig; + + /** + * Where a press on the field itself lands. The segments are read from the inputs rather than from this render, so + * a press arriving as the field is being cleared aims at what the field holds by then and not a moment earlier. + */ + const focusFirstUnfilledSegment = () => { + const parts = getDateMaskParts(mask); + const target = parts.find((part) => (segmentRefs.current[part.name]?.value?.length ?? 0) < part.placeholder.length) ?? parts.at(-1); + + if (!target) { + return; + } + + focusSegment(target.name); + }; /** * The field's own blur handler is what a form hangs its validation on, so it has to wait for the whole field to be @@ -180,7 +200,7 @@ function DateSegmentsInput({ diff --git a/src/components/TextInput/BaseTextInput/types.ts b/src/components/TextInput/BaseTextInput/types.ts index b8c5d5223e92..5a618c50b0e2 100644 --- a/src/components/TextInput/BaseTextInput/types.ts +++ b/src/components/TextInput/BaseTextInput/types.ts @@ -22,7 +22,6 @@ type DateSegmentsConfig = { getSegmentProps: UseDateSegmentInputResult['getSegmentProps']; focusRequest: UseDateSegmentInputResult['focusRequest']; - requestInitialFocus: UseDateSegmentInputResult['requestInitialFocus']; onFieldBlur: UseDateSegmentInputResult['onFieldBlur']; }; diff --git a/src/hooks/useDateSegmentInput.ts b/src/hooks/useDateSegmentInput.ts index 89b2bb28b172..1f20f1434c19 100644 --- a/src/hooks/useDateSegmentInput.ts +++ b/src/hooks/useDateSegmentInput.ts @@ -9,7 +9,6 @@ import { DATE_SEGMENT_NAMES, EMPTY_SEGMENTS, getAdjacentSegmentName, - getFirstUnfilledSegmentName, getISODateFromSegments, getSegmentDisplay, getSegmentsFromISODate, @@ -87,9 +86,6 @@ type UseDateSegmentInputResult = { getSegmentProps: (name: DateSegmentName) => DateSegmentProps; - /** Sends focus to the first segment still to be filled in, for a press that landed on the field but not on a segment */ - requestInitialFocus: () => void; - /** Called once focus has left the field altogether rather than moved between segments */ onFieldBlur: () => void; }; @@ -257,7 +253,6 @@ export default function useDateSegmentInput({value, isEnabled, minDate, maxDate, viewDateVersion: 0, hasTypedDigits: false, getSegmentProps: () => ({value: '', onKeyPress: () => {}, onChangeText: () => {}, onFocus: () => {}}), - requestInitialFocus: () => {}, onFieldBlur: () => {}, }; } @@ -278,8 +273,6 @@ export default function useDateSegmentInput({value, isEnabled, minDate, maxDate, onChangeText: handleChangeText, onFocus: handleSegmentFocus, }), - // A date with no gaps in it has no segment waiting to be filled, so the last one takes the focus - requestInitialFocus: () => enterSegment(getFirstUnfilledSegmentName(displayedSegments) ?? LAST_SEGMENT_NAME), onFieldBlur: handleFieldBlur, }; } From aa094c3883d4759c83e8003412e27ac3f0dba473 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 18 Sep 2026 23:58:33 +0530 Subject: [PATCH 21/23] fix: keep a committed date from restarting the segment being typed Signed-off-by: krishna2323 --- src/hooks/useDateSegmentInput.ts | 4 +++- src/libs/DateInputMaskUtils.ts | 12 +++++++++--- tests/unit/DateInputMaskUtilsTest.ts | 19 +++++++++++++++++-- 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/hooks/useDateSegmentInput.ts b/src/hooks/useDateSegmentInput.ts index 1f20f1434c19..059410605f47 100644 --- a/src/hooks/useDateSegmentInput.ts +++ b/src/hooks/useDateSegmentInput.ts @@ -111,7 +111,9 @@ export default function useDateSegmentInput({value, isEnabled, minDate, maxDate, if (value !== appliedValue) { setAppliedValue(value); - if (isEditing) { + // A date this field has just committed already agrees with the segments, and seeding from it would replace a + // half typed segment with the padded form it is showing. The next digit would then start the segment over. + if (isEditing && value !== getISODateFromSegments(segments)) { setSegments(getSegmentsFromISODate(value)); } } diff --git a/src/libs/DateInputMaskUtils.ts b/src/libs/DateInputMaskUtils.ts index ff04ce94ec0d..e9186beb0b0a 100644 --- a/src/libs/DateInputMaskUtils.ts +++ b/src/libs/DateInputMaskUtils.ts @@ -273,13 +273,19 @@ function getSegmentsFromText(text: string): DateSegments { return filled; } -/** Returns the date in the format the rest of the app stores, or undefined while any segment is still unfinished */ +/** + * Returns the date in the format the rest of the app stores, or undefined while a segment is empty or reads as an + * impossible date. + * + * A zero padded segment counts from one digit, since that is already what it shows. A day of 3 therefore reads as the + * third rather than waiting to find out whether it was going to be the 30th, and typing that second digit revises it. + */ function getISODateFromSegments(segments: DateSegments): string | undefined { - if (segments.year.length !== YEAR_LENGTH || segments.month.length !== SEGMENT_LENGTH || segments.day.length !== SEGMENT_LENGTH) { + if (segments.year.length !== YEAR_LENGTH || !segments.month || !segments.day) { return undefined; } - const isoDate = `${segments.year}-${segments.month}-${segments.day}`; + const isoDate = `${segments.year}-${getSegmentDisplay(segments, 'month')}-${getSegmentDisplay(segments, 'day')}`; return isValid(parse(isoDate, CONST.DATE.FNS_FORMAT_STRING, new Date())) ? isoDate : undefined; } diff --git a/tests/unit/DateInputMaskUtilsTest.ts b/tests/unit/DateInputMaskUtilsTest.ts index 0014601659c9..f1ce46ae64e2 100644 --- a/tests/unit/DateInputMaskUtilsTest.ts +++ b/tests/unit/DateInputMaskUtilsTest.ts @@ -220,11 +220,26 @@ describe('DateInputMaskUtils', () => { expect(getISODateFromSegments(segments('2026', '09', '18'))).toBe('2026-09-18'); }); - it('returns undefined while a segment is unfinished', () => { - expect(getISODateFromSegments(segments('2026', '9', '18'))).toBeUndefined(); + it('reads a zero padded segment from one digit, since that is what it already shows', () => { + expect(getISODateFromSegments(segments('2026', '9', '18'))).toBe('2026-09-18'); + expect(getISODateFromSegments(segments('2026', '09', '3'))).toBe('2026-09-03'); + }); + + it('returns undefined while a segment is empty or the year is unfinished', () => { expect(getISODateFromSegments(segments('202', '09', '18'))).toBeUndefined(); + expect(getISODateFromSegments(segments('2026', '', '18'))).toBeUndefined(); + expect(getISODateFromSegments(segments('2026', '09', ''))).toBeUndefined(); expect(getISODateFromSegments(EMPTY)).toBeUndefined(); }); + + it('refuses a segment that cannot be a month or a day, which only a zero can be', () => { + expect(getISODateFromSegments(segments('2026', '0', '18'))).toBeUndefined(); + expect(getISODateFromSegments(segments('2026', '09', '0'))).toBeUndefined(); + }); + + it('refuses a day the typed month does not have', () => { + expect(getISODateFromSegments(segments('2026', '02', '31'))).toBeUndefined(); + }); }); describe('getSegmentsFromISODate', () => { From 6970cfffa4884494e9cbc6419015670d5a7b0cdb Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Sat, 19 Sep 2026 00:02:42 +0530 Subject: [PATCH 22/23] fix: drop a date segment keystroke that cannot complete a leading zero Signed-off-by: krishna2323 --- src/components/TextInput/BaseTextInput/types.ts | 2 +- src/hooks/useDateSegmentInput.ts | 2 +- src/hooks/useRemeasureOnScroll/types.ts | 1 - src/libs/DateInputMaskUtils.ts | 8 +++++++- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/components/TextInput/BaseTextInput/types.ts b/src/components/TextInput/BaseTextInput/types.ts index 5a618c50b0e2..a5fb79d5df48 100644 --- a/src/components/TextInput/BaseTextInput/types.ts +++ b/src/components/TextInput/BaseTextInput/types.ts @@ -212,4 +212,4 @@ type BaseTextInputRef = HTMLFormElement | AnimatedTextInputRef; type BaseTextInputProps = CustomBaseTextInputProps & TextInputProps; -export type {BaseTextInputProps, BaseTextInputRef, DateSegmentsConfig, InputType}; +export type {BaseTextInputProps, BaseTextInputRef, InputType}; diff --git a/src/hooks/useDateSegmentInput.ts b/src/hooks/useDateSegmentInput.ts index 059410605f47..76f3c3d2af1f 100644 --- a/src/hooks/useDateSegmentInput.ts +++ b/src/hooks/useDateSegmentInput.ts @@ -279,4 +279,4 @@ export default function useDateSegmentInput({value, isEnabled, minDate, maxDate, }; } -export type {DateSegmentProps, SegmentFocusRequest, UseDateSegmentInputParams, UseDateSegmentInputResult}; +export type {UseDateSegmentInputParams, UseDateSegmentInputResult}; diff --git a/src/hooks/useRemeasureOnScroll/types.ts b/src/hooks/useRemeasureOnScroll/types.ts index cee7bc213303..10ab51f826d3 100644 --- a/src/hooks/useRemeasureOnScroll/types.ts +++ b/src/hooks/useRemeasureOnScroll/types.ts @@ -9,4 +9,3 @@ type UseRemeasureOnScrollParams = { type UseRemeasureOnScroll = (params: UseRemeasureOnScrollParams) => void; export default UseRemeasureOnScroll; -export type {UseRemeasureOnScrollParams}; diff --git a/src/libs/DateInputMaskUtils.ts b/src/libs/DateInputMaskUtils.ts index e9186beb0b0a..b3cefeac4dea 100644 --- a/src/libs/DateInputMaskUtils.ts +++ b/src/libs/DateInputMaskUtils.ts @@ -157,6 +157,12 @@ function typeDigitIntoOneSegment(name: DateSegmentName, typedSoFar: string, digi return {value: combined, shouldAdvance: true}; } + // Finishing the segment early only works while what it already holds can stand on its own. A leading zero + // cannot, so the keystroke is dropped and the segment keeps waiting for a digit that completes it. + if (Number(current) < FIRST_MONTH) { + return {value: current, shouldAdvance: false}; + } + return {value: current.padStart(SEGMENT_LENGTH, '0'), shouldAdvance: true, carry: digit}; } @@ -310,4 +316,4 @@ export { removeLastDigit, typeDigitIntoSegments, }; -export type {DateMaskPart, DateSegmentName, DateSegments}; +export type {DateSegmentName, DateSegments}; From c763339876066f7e8c5678cb6c1d6c6077edc235 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Sat, 19 Sep 2026 00:09:20 +0530 Subject: [PATCH 23/23] feat: keep the unreached mask letters on screen while a date is typed Signed-off-by: krishna2323 --- src/components/DateSegmentsInput.tsx | 88 ++++++++++++++++------------ 1 file changed, 51 insertions(+), 37 deletions(-) diff --git a/src/components/DateSegmentsInput.tsx b/src/components/DateSegmentsInput.tsx index 017044e772f3..f2c9412bc8e3 100644 --- a/src/components/DateSegmentsInput.tsx +++ b/src/components/DateSegmentsInput.tsx @@ -46,6 +46,9 @@ const SIZED_TO_CONTENT = {flexGrow: 0, flexShrink: 0, flexBasis: 'auto', width: /** Holds a copy of a segment's text purely to be measured, so it must not take part in the layout it is measuring */ const MEASURED_OFF_LAYOUT = {position: 'absolute', opacity: 0} as const; +/** Sits over the segment's own input, which is why the input can show the typed digits alone */ +const REMAINDER_OVERLAY = {position: 'absolute', left: 0, top: 0} as const; + function DateSegmentsInput({ dateSegmentsConfig, style, @@ -140,7 +143,10 @@ function DateSegmentsInput({ > {getDateMaskParts(mask).map((part) => { const segmentProps = getSegmentProps(part.name); - const measuredText = segmentProps.value || part.placeholder; + // The mask letters a half typed segment has not reached yet, which stay on screen in the placeholder + // color so the shape of the date is still readable while it is being filled in. + const remainder = part.placeholder.slice(segmentProps.value.length); + const measuredText = `${segmentProps.value}${remainder}`; const width = (measuredWidths[part.name] ?? measuredText.length * ESTIMATED_CHARACTER_WIDTH) + CARET_ALLOWANCE; return ( @@ -155,42 +161,50 @@ function DateSegmentsInput({ > {measuredText} - { - segmentRefs.current[part.name] = element; - - // The field's own ref has to lead somewhere, and the year is where typing starts - if (part.name !== 'year') { - return; - } - if (typeof ref === 'function') { - ref(element); - } else if (ref && 'current' in ref) { - // eslint-disable-next-line no-param-reassign - ref.current = element; - } - }} - style={[style, NO_HORIZONTAL_PADDING, SIZED_TO_CONTENT, {width}]} - value={segmentProps.value} - placeholder={part.placeholder} - placeholderTextColor={placeholderTextColor} - onKeyPress={segmentProps.onKeyPress} - onChangeText={segmentProps.onChangeText} - onFocus={(event) => { - clearTimeout(blurTimeoutRef.current); - segmentProps.onFocus(); - onFocus?.(event); - }} - onBlur={handleSegmentBlur} - onPressOut={onPressOut} - accessibilityLabel={translate(`common.dateSegments.${part.name}`)} - inputMode="numeric" - disabled={disabled} - readOnly={readOnly} - forwardedFSClass={forwardedFSClass} - aria-describedby={ariaDescribedBy} - aria-invalid={ariaInvalid} - /> + + { + segmentRefs.current[part.name] = element; + + // The field's own ref has to lead somewhere, and the year is where typing starts + if (part.name !== 'year') { + return; + } + if (typeof ref === 'function') { + ref(element); + } else if (ref && 'current' in ref) { + // eslint-disable-next-line no-param-reassign + ref.current = element; + } + }} + style={[style, NO_HORIZONTAL_PADDING, styles.w100]} + value={segmentProps.value} + onKeyPress={segmentProps.onKeyPress} + onChangeText={segmentProps.onChangeText} + onFocus={(event) => { + clearTimeout(blurTimeoutRef.current); + segmentProps.onFocus(); + onFocus?.(event); + }} + onBlur={handleSegmentBlur} + onPressOut={onPressOut} + accessibilityLabel={translate(`common.dateSegments.${part.name}`)} + inputMode="numeric" + disabled={disabled} + readOnly={readOnly} + forwardedFSClass={forwardedFSClass} + aria-describedby={ariaDescribedBy} + aria-invalid={ariaInvalid} + /> + {/* The digits already typed are repeated invisibly so the text flow puts the remaining mask + letters exactly where the input's own text ends, without measuring anything. */} + {!!remainder && ( + + {segmentProps.value} + {remainder} + + )} + {!!part.separator && {part.separator}} );