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 > 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; @@ -52,6 +58,15 @@ type CalendarPickerProps = { /** Whether Month/Year right-docked picker modals should keep backdrop in narrow pane context */ shouldEnableMonthYearBackdropInNarrowPane?: boolean; + + /** + * Moves the calendar to this month without selecting a day, so it can follow a date being typed into the input. + * The calendar still owns its own view, so its arrows and month picker keep working between updates. + */ + viewDate?: Date; + + /** Changes every time `viewDate` is asserted, including when it repeats the month the calendar already shows */ + viewDateVersion?: number; }; function getInitialCurrentDateView(value: Date | string, minDate: Date, maxDate: Date) { @@ -75,16 +90,25 @@ function getInitialCurrentDateView(value: Date | string, minDate: Date, maxDate: return initialCurrentDateView; } +// Keeps the day inside the target month, since setYear alone turns February 29 into March 1 on a non leap year +function setYearKeepingDay(date: Date, year: number) { + const firstOfTargetMonth = setYear(setDate(date, 1), year); + return setDate(firstOfTargetMonth, Math.min(date.getDate(), getDaysInMonth(firstOfTargetMonth))); +} + function CalendarPicker({ value = new Date(), minDate = setYear(new Date(), CONST.CALENDAR_PICKER.MIN_YEAR), maxDate = setYear(new Date(), CONST.CALENDAR_PICKER.MAX_YEAR), onSelected, + onMonthOrYearSelected, DayComponent = Day, selectableDates, headerContainerStyle, containerStyle, shouldEnableMonthYearBackdropInNarrowPane = false, + viewDate, + viewDateVersion = 0, }: CalendarPickerProps) { // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth} = useResponsiveLayout(); @@ -94,10 +118,19 @@ function CalendarPicker({ const pressableRef = useRef(null); const monthPressableRef = useRef(null); const [currentDateView, setCurrentDateView] = useState(() => getInitialCurrentDateView(value, minDate, maxDate)); + const [appliedViewDateVersion, setAppliedViewDateVersion] = useState(viewDateVersion); const [isYearPickerVisible, setIsYearPickerVisible] = useState(false); const [isMonthPickerVisible, setIsMonthPickerVisible] = useState(false); const isFirstRender = useRef(true); + // Catching up with the caller here rather than in an effect keeps the view and the month matrix in step within a + // single render, so the calendar never paints the old month first. The date arrives already inside the allowed + // range, and is deliberately not clamped: clamping would show the limit's month instead of the typed one. + if (viewDate && viewDateVersion !== appliedViewDateVersion) { + setAppliedViewDateVersion(viewDateVersion); + setCurrentDateView(viewDate); + } + const currentMonthView = currentDateView.getMonth(); const currentYearView = currentDateView.getFullYear(); const calendarDaysMatrix = generateMonthMatrix(currentYearView, currentMonthView); @@ -117,21 +150,22 @@ function CalendarPicker({ ); const onYearSelected = (year: number) => { - setCurrentDateView((prev) => { - const newCurrentDateView = setYear(new Date(prev), year); - setYears((prevYears) => - prevYears.map((item) => ({ - ...item, - isSelected: item.value === newCurrentDateView.getFullYear(), - })), - ); - return newCurrentDateView; - }); + const newCurrentDateView = setYearKeepingDay(new Date(currentDateView), year); + setCurrentDateView(newCurrentDateView); + setYears((prevYears) => + prevYears.map((item) => ({ + ...item, + isSelected: item.value === newCurrentDateView.getFullYear(), + })), + ); + onMonthOrYearSelected?.(format(newCurrentDateView, CONST.DATE.FNS_FORMAT_STRING)); requestAnimationFrame(() => setIsYearPickerVisible(false)); }; const onMonthSelected = (month: number) => { - setCurrentDateView((prev) => setMonth(new Date(prev), month)); + const newCurrentDateView = setMonth(new Date(currentDateView), month); + setCurrentDateView(newCurrentDateView); + onMonthOrYearSelected?.(format(newCurrentDateView, CONST.DATE.FNS_FORMAT_STRING)); requestAnimationFrame(() => setIsMonthPickerVisible(false)); }; diff --git a/src/components/DatePicker/DatePickerModal.tsx b/src/components/DatePicker/DatePickerModal.tsx index 5ea4f13a430e..160a31826898 100644 --- a/src/components/DatePicker/DatePickerModal.tsx +++ b/src/components/DatePicker/DatePickerModal.tsx @@ -42,13 +42,23 @@ function DatePickerModal({ anchorPosition, anchorAlignment = DEFAULT_ANCHOR_ORIGIN, onSelected, + onMonthOrYearSelected, shouldCloseWhenBrowserNavigationChanged = false, shouldPositionFromTop = false, forwardedFSClass, shouldEnableMonthYearBackdropInNarrowPane = false, + anchorRef: anchorRefProp, + withoutOverlay = false, + shouldAllowWithoutOverlayInNarrowPane = false, + shouldCloseOnWheel = true, + viewDate, + viewDateVersion, }: DatePickerProps) { const [selectedDate, setSelectedDate] = useState(value ?? defaultValue ?? undefined); - const anchorRef = useRef(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 @@ -64,13 +74,22 @@ function DatePickerModal({ } }, [formID, inputID, selectedDate, shouldSaveDraft, value]); - const handleDateSelection = (newValue: string) => { - onSelected?.(newValue); + const applySelection = (newValue: string) => { onTouched?.(); onInputChange?.(newValue); setSelectedDate(newValue); }; + const handleDateSelection = (newValue: string) => { + onSelected?.(newValue); + applySelection(newValue); + }; + + const handleMonthOrYearSelection = (newValue: string) => { + onMonthOrYearSelected?.(newValue); + applySelection(newValue); + }; + // Pass the CalendarPicker's existing bottom padding (pb4) as the base style so the safe-area padding is // added on top of it instead of overriding it (containerStyle is applied after pb4 in CalendarPicker). // The modal doesn't render an offline indicator inside it, so disable the offline-indicator padding — @@ -95,14 +114,20 @@ function DatePickerModal({ forwardedFSClass={forwardedFSClass} shouldDisplayBelowModals enableEdgeToEdgeBottomSafeAreaPadding + withoutOverlay={withoutOverlay} + shouldAllowWithoutOverlayInNarrowPane={shouldAllowWithoutOverlayInNarrowPane} + shouldCloseOnWheel={shouldCloseOnWheel} > ); diff --git a/src/components/DatePicker/index.tsx b/src/components/DatePicker/index.tsx index 3cede0b289ea..820884080a58 100644 --- a/src/components/DatePicker/index.tsx +++ b/src/components/DatePicker/index.tsx @@ -3,12 +3,15 @@ import type {BaseTextInputProps, BaseTextInputRef} from '@components/TextInput/B import useAccessibilityAnnouncement from '@hooks/useAccessibilityAnnouncement'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; +import useDateSegmentInput from '@hooks/useDateSegmentInput'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; +import useRemeasureOnScroll from '@hooks/useRemeasureOnScroll'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import ComposerFocusManager from '@libs/ComposerFocusManager'; +import isTypedDateInputSupported from '@libs/isTypedDateInputSupported'; import {isNumeric} from '@libs/ValidationUtils'; import {setDraftValues} from '@userActions/FormActions'; @@ -69,6 +72,20 @@ function DatePicker({ // picker was dismissed before it resolved. const openIntentRef = useRef(false); + const shouldAllowTyping = isTypedDateInputSupported(); + const dateMask = translate('common.dateFormat'); + + // 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); + }; + + // The hook is the single gate on typing. When the platform does not allow it, the handlers it returns are no-ops + // and the value passes straight through, so the call sites below do not have to check again. + const segmentInput = useDateSegmentInput({value: selectedDate, isEnabled: shouldAllowTyping, minDate, maxDate, onCommit: commitDate}); + const {inputCallbackRef: autoFocusCallbackRef, cancelAutoFocus} = useAutoFocusInput(); const autoFocusCallbackRefRef = useRef(autoFocusCallbackRef); autoFocusCallbackRefRef.current = autoFocusCallbackRef; @@ -113,11 +130,21 @@ 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(); - // 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,31 +168,40 @@ function DatePicker({ }; openPicker(); - }, [shouldDeferShowUntilPositioned, shouldDismissKeyboardBeforeShow, calculatePopoverPosition, cancelAutoFocus, setPickerVisibility]); + }, [isModalVisible, 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) => { + // The field focuses its own input on any press it is not told to leave alone, which would be the year + // whichever segment was actually pressed. The segments are focused by the press itself instead. if ('preventDefault' in event) { event.preventDefault(); } + showDatePickerModal(); }, [showDatePickerModal], ); + // 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 = () => { + showDatePickerModal(); + }; + const handleInputKeyPress = useCallback( (event: TextInputKeyPressEvent) => { if (!isNumeric(event.nativeEvent.key)) { @@ -185,6 +221,20 @@ 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}); + + // 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?.(''); @@ -228,32 +278,45 @@ function DatePicker({ showDatePickerModal()} - onSubmitEditing={() => showDatePickerModal()} - onKeyPress={handleInputKeyPress} + hideFocusedState={shouldDismissKeyboardBeforeShow && !shouldAllowTyping} + onPress={shouldDismissKeyboardBeforeShow || shouldAllowTyping ? handlePress : () => showDatePickerModal()} + onSubmitEditing={shouldAllowTyping ? undefined : () => showDatePickerModal()} + onFocus={shouldAllowTyping ? handleFocus : undefined} + onKeyPress={shouldAllowTyping ? undefined : handleInputKeyPress} textInputContainerStyles={isModalVisible ? styles.borderColorFocus : {}} shouldHideClearButton={shouldHideClearButton} onClearInput={handleClear} forwardedFSClass={forwardedFSClass} autoComplete={autoComplete} - disableKeyboard + disableKeyboard={!shouldAllowTyping} rightHandSideComponent={rightHandSideComponent} /> @@ -270,6 +333,13 @@ function DatePicker({ shouldPositionFromTop={!isInverted} forwardedFSClass={forwardedFSClass} shouldCloseWhenBrowserNavigationChanged + anchorRef={anchorRef} + withoutOverlay={shouldAllowTyping} + 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 8f54da70932f..22cad09fe57b 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,24 @@ 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; + + /** 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/components/DateSegmentsInput.tsx b/src/components/DateSegmentsInput.tsx new file mode 100644 index 000000000000..f2c9412bc8e3 --- /dev/null +++ b/src/components/DateSegmentsInput.tsx @@ -0,0 +1,227 @@ +/** + * 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, useState} from 'react'; +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'; + +/** Leaves the caret somewhere to sit, since a segment sized to its text exactly would clip it at the end */ +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; + +/** + * 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', 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; + +/** 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, + 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>>({}); + // 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); + // 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; + + /** + * Arriving at a segment rests the caret after the digits already in it, rather than wherever the browser last left + * the caret there, so a half typed month reads as 02 and not 0 followed by a caret and a 2. + */ + const focusSegment = (name: DateSegmentName) => { + 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; + focusSegment(focusRequest.name); + }, [focusRequest]); + + useEffect(() => () => clearTimeout(blurTimeoutRef.current), []); + + if (!dateSegmentsConfig) { + return null; + } + + 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 + * left rather than run on every hop between segments. + */ + const handleSegmentBlur: NonNullable = (event) => { + clearTimeout(blurTimeoutRef.current); + 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); + // 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 ( + + { + const layoutWidth = event.nativeEvent.layout.width; + + setMeasuredWidths((previous) => (previous[part.name] === layoutWidth ? previous : {...previous, [part.name]: layoutWidth})); + }} + > + {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, 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}} + + ); + })} + {/* 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. */} + + + ); +} + +DateSegmentsInput.displayName = 'DateSegmentsInput'; + +export default DateSegmentsInput; diff --git a/src/components/Popover/index.tsx b/src/components/Popover/index.tsx index a8b956a8417a..819cfd61b21b 100644 --- a/src/components/Popover/index.tsx +++ b/src/components/Popover/index.tsx @@ -34,6 +34,7 @@ function Popover(props: PopoverProps) { animationInTiming = CONST.MENU_ANIMATION_DURATION, disableAnimation = true, withoutOverlay = false, + shouldAllowWithoutOverlayInNarrowPane = false, anchorPosition = {}, anchorRef = () => {}, animationIn = 'fadeIn', @@ -133,7 +134,12 @@ function Popover(props: PopoverProps) { ); } - if (withoutOverlay && !shouldUseNarrowLayout) { + // 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/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; 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..a5fb79d5df48 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']; diff --git a/src/hooks/useDateSegmentInput.ts b/src/hooks/useDateSegmentInput.ts new file mode 100644 index 000000000000..76f3c3d2af1f --- /dev/null +++ b/src/hooks/useDateSegmentInput.ts @@ -0,0 +1,282 @@ +/** + * 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, + getISODateFromSegments, + getSegmentDisplay, + getSegmentsFromISODate, + getSegmentsFromText, + getViewDateFromSegments, + hasAnySegment, + removeLastDigit, + typeDigitIntoSegments, +} from '@libs/DateInputMaskUtils'; +import type {DateSegmentName, DateSegments} from '@libs/DateInputMaskUtils'; +import {isNumeric} from '@libs/ValidationUtils'; + +import type {TextInputKeyPressEvent} from 'react-native'; + +import {useState} from 'react'; + +const LAST_SEGMENT_NAME = DATE_SEGMENT_NAMES[DATE_SEGMENT_NAMES.length - 1]; + +const BACKSPACE_KEY = 'Backspace'; +const DELETE_KEY = 'Delete'; +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; + + /** 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; +}; + +/** 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 committed date, shown while the field is not being edited */ + displayValue: string; + + /** 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; + + /** 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; + + getSegmentProps: (name: DateSegmentName) => DateSegmentProps; + + /** 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, 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 [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); + // 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 + // 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); + + // 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)); + } + } + + const requestFocus = (name: DateSegmentName) => { + setFocusRequest((previous) => ({name, version: (previous?.version ?? 0) + 1})); + }; + + /** 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) => { + const isoDate = getISODateFromSegments(newSegments); + if (!isoDate) { + return; + } + + 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); + assertViewDate(getViewDateFromSegments(newSegments, (viewDate ?? new Date()).getMonth(), minDate, maxDate)); + commitIfComplete(newSegments); + }; + + const handleKeyPress = (name: DateSegmentName, event: TextInputKeyPressEvent) => { + const key = event.nativeEvent.key; + + if (isNumeric(key)) { + event.preventDefault(); + const result = typeDigitIntoSegments(segments, name, key, shouldOverwrite); + setShouldOverwrite(false); + applySegments(result.segments); + + if (result.nextSegmentName) { + enterSegment(result.nextSegmentName); + } + return; + } + + if (isMoveKey(key)) { + event.preventDefault(); + enterSegment(getAdjacentSegmentName(name, MOVE_KEYS[key])); + return; + } + + if (key === BACKSPACE_KEY || key === DELETE_KEY) { + event.preventDefault(); + const trimmedSegments = removeLastDigit(segments, name); + + // An empty segment has nothing to delete, so the keystroke falls back to leaving it + if (!trimmedSegments) { + enterSegment(getAdjacentSegmentName(name, -1)); + return; + } + + applySegments(trimmedSegments); + setShouldOverwrite(false); + return; + } + + 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(); + enterSegment(getAdjacentSegmentName(name, 1)); + }; + + // 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); + enterSegment(LAST_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)); + setIsEditing(true); + }; + + // An unfinished edit is dropped rather than cleared, so leaving the field restores the last committed date + const handleFieldBlur = () => { + setIsEditing(false); + setSegments(EMPTY_SEGMENTS); + setViewDate(undefined); + setFocusRequest(undefined); + setShouldOverwrite(false); + }; + + if (!isEnabled) { + return { + displayValue: value, + isEditing: false, + focusRequest: undefined, + viewDate: undefined, + viewDateVersion: 0, + hasTypedDigits: false, + getSegmentProps: () => ({value: '', onKeyPress: () => {}, onChangeText: () => {}, onFocus: () => {}}), + onFieldBlur: () => {}, + }; + } + + // 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, + focusRequest, + viewDate: isEditing ? viewDate : undefined, + viewDateVersion, + hasTypedDigits: isEditing && hasAnySegment(segments), + getSegmentProps: (name: DateSegmentName) => ({ + value: getSegmentDisplay(displayedSegments, name), + onKeyPress: (event: TextInputKeyPressEvent) => handleKeyPress(name, event), + onChangeText: handleChangeText, + onFocus: handleSegmentFocus, + }), + onFieldBlur: handleFieldBlur, + }; +} + +export type {UseDateSegmentInputParams, UseDateSegmentInputResult}; 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..10ab51f826d3 --- /dev/null +++ b/src/hooks/useRemeasureOnScroll/types.ts @@ -0,0 +1,11 @@ +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; 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 new file mode 100644 index 000000000000..b3cefeac4dea --- /dev/null +++ b/src/libs/DateInputMaskUtils.ts @@ -0,0 +1,319 @@ +/** + * 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 {endOfMonth, 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; + +/** + * 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; + +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 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; +}; + +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; +} + +/** + * 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; +} + +/** + * 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 getSegmentDisplay(segments: DateSegments, name: DateSegmentName): string { + const digits = segments[name].slice(0, getSegmentLength(name)); + + if (!digits) { + return ''; + } + + 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 */ +function getFollowingSegmentName(name: DateSegmentName): DateSegmentName | undefined { + return DATE_SEGMENT_NAMES.at(DATE_SEGMENT_NAMES.indexOf(name) + 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]; +} + +type SegmentDigitResult = { + /** What the segment now reads */ + value: string; + + /** Whether the segment is finished with, so the caret belongs in the next one */ + shouldAdvance: boolean; + + /** A digit this segment could not take, which the next one receives instead */ + carry?: string; +}; + +/** + * Adds one typed digit to a single segment, without knowing about the others. A digit that cannot extend what is + * already there is handed on rather than dropped, so typing 1 then 3 into the month reads as January and starts the + * day off with the 3. + */ +function typeDigitIntoOneSegment(name: DateSegmentName, typedSoFar: string, digit: string): SegmentDigitResult { + const current = typedSoFar.length >= getSegmentLength(name) ? '' : typedSoFar; + + if (name === 'year') { + const year = `${current}${digit}`.slice(0, YEAR_LENGTH); + + return {value: year, shouldAdvance: year.length === YEAR_LENGTH}; + } + + const limits = SEGMENT_LIMITS[name]; + + if (current.length === 1) { + const combined = `${current}${digit}`; + const combinedNumber = Number(combined); + + if (combinedNumber >= FIRST_MONTH && combinedNumber <= limits.max) { + 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}; + } + + // 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}; + } + + return {value: digit, shouldAdvance: false}; +} + +/** + * 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; + } + + nextSegmentName = followingName; + if (!result.carry) { + break; + } + + currentName = followingName; + typedSoFar = ''; + currentDigit = result.carry; + } + + 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]) { + return undefined; + } + + return {...segments, [name]: segments[name].slice(0, -1)}; +} + +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), + }; +} + +/** 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)); +} + +/** 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 = getFirstUnfilledSegmentName(filled); + if (!name) { + break; + } + + filled = typeDigitIntoSegments(filled, name, digit).segments; + } + + return filled; +} + +/** + * 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 || !segments.day) { + return undefined; + } + + const isoDate = `${segments.year}-${getSegmentDisplay(segments, 'month')}-${getSegmentDisplay(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, + getAdjacentSegmentName, + getDateMaskParts, + getFirstUnfilledSegmentName, + getISODateFromSegments, + getSegmentDisplay, + getSegmentLength, + getSegmentsFromISODate, + getSegmentsFromText, + getViewDateFromSegments, + hasAnySegment, + removeLastDigit, + typeDigitIntoSegments, +}; +export type {DateSegmentName, 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/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(); diff --git a/tests/unit/DateInputMaskUtilsTest.ts b/tests/unit/DateInputMaskUtilsTest.ts new file mode 100644 index 000000000000..f1ce46ae64e2 --- /dev/null +++ b/tests/unit/DateInputMaskUtilsTest.ts @@ -0,0 +1,263 @@ +import { + getAdjacentSegmentName, + getDateMaskParts, + getFirstUnfilledSegmentName, + getISODateFromSegments, + getSegmentDisplay, + getSegmentLength, + getSegmentsFromISODate, + getSegmentsFromText, + getViewDateFromSegments, + hasAnySegment, + removeLastDigit, + typeDigitIntoSegments, +} 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('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('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(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(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(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('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('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('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}); + + // 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('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('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('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', '', '')); + expect(removeLastDigit(segments('2', '', ''), 'year')).toEqual(EMPTY); + }); + + it('reports that an empty segment had nothing to drop', () => { + expect(removeLastDigit(EMPTY, 'year')).toBeUndefined(); + }); + }); + + 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('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(getSegmentDisplay(segments('2026', '1', ''), 'month')).toBe('01'); + expect(getSegmentDisplay(segments('2026', '09', '2'), 'day')).toBe('02'); + }); + + 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('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('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('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); + }); + }); + + 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('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')); + 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('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', () => { + 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('hasAnySegment', () => { + it('reports whether anything has been filled in', () => { + expect(hasAnySegment(EMPTY)).toBe(false); + expect(hasAnySegment(segments('', '09', ''))).toBe(true); + }); + }); +});