From 241e52fb6026d7d70140229a819a607139143b4f Mon Sep 17 00:00:00 2001 From: "Jaikumar.T.J" Date: Fri, 26 Jun 2026 16:13:52 +0200 Subject: [PATCH 1/2] chore: migrate components to functional and update props --- .changeset/migrate-downshift-usecombobox.md | 7 + .../fields/date-field/src/date-field.spec.js | 2 +- .../src/date-range-field.spec.js | 2 +- .../src/date-time-field.spec.js | 2 +- .../inputs/date-input/src/date-input.tsx | 440 +++++---- .../date-range-input/src/date-range-input.tsx | 806 ++++++++-------- .../date-time-input/src/date-time-input.tsx | 903 ++++++++---------- 7 files changed, 1026 insertions(+), 1136 deletions(-) create mode 100644 .changeset/migrate-downshift-usecombobox.md diff --git a/.changeset/migrate-downshift-usecombobox.md b/.changeset/migrate-downshift-usecombobox.md new file mode 100644 index 0000000000..df44c00bb3 --- /dev/null +++ b/.changeset/migrate-downshift-usecombobox.md @@ -0,0 +1,7 @@ +--- +'@commercetools-uikit/date-input': patch +'@commercetools-uikit/date-range-input': patch +'@commercetools-uikit/date-time-input': patch +--- + +Migrate date inputs from Downshift render-prop API to `useCombobox` hook to fix `TypeError: t.contains is not a function` crash in downshift@9.3.6 with React 19. diff --git a/packages/components/fields/date-field/src/date-field.spec.js b/packages/components/fields/date-field/src/date-field.spec.js index dd3f11775e..b963dc6696 100644 --- a/packages/components/fields/date-field/src/date-field.spec.js +++ b/packages/components/fields/date-field/src/date-field.spec.js @@ -171,7 +171,7 @@ describe('when field is touched and has errors', () => { touched: true, errors: { missing: true }, }); - expect(getByRole('textbox')).toHaveAccessibleErrorMessage( + expect(getByRole('combobox')).toHaveAccessibleErrorMessage( /field is required/i ); }); diff --git a/packages/components/fields/date-range-field/src/date-range-field.spec.js b/packages/components/fields/date-range-field/src/date-range-field.spec.js index 92fa76f773..dc459b53dc 100644 --- a/packages/components/fields/date-range-field/src/date-range-field.spec.js +++ b/packages/components/fields/date-range-field/src/date-range-field.spec.js @@ -173,7 +173,7 @@ describe('when field is touched and has errors', () => { touched: true, errors: { missing: true }, }); - expect(getByRole('textbox')).toHaveAccessibleErrorMessage( + expect(getByRole('combobox')).toHaveAccessibleErrorMessage( /field is required/i ); }); diff --git a/packages/components/fields/date-time-field/src/date-time-field.spec.js b/packages/components/fields/date-time-field/src/date-time-field.spec.js index 07833ad24c..89dc9ff99f 100644 --- a/packages/components/fields/date-time-field/src/date-time-field.spec.js +++ b/packages/components/fields/date-time-field/src/date-time-field.spec.js @@ -163,7 +163,7 @@ describe('when field is touched and has errors', () => { touched: true, errors: { missing: true }, }); - expect(getByRole('textbox')).toHaveAccessibleErrorMessage( + expect(getByRole('combobox')).toHaveAccessibleErrorMessage( /field is required/i ); }); diff --git a/packages/components/inputs/date-input/src/date-input.tsx b/packages/components/inputs/date-input/src/date-input.tsx index 2f72c96861..3e7cba38c2 100644 --- a/packages/components/inputs/date-input/src/date-input.tsx +++ b/packages/components/inputs/date-input/src/date-input.tsx @@ -5,7 +5,7 @@ import { type KeyboardEvent, type FocusEventHandler, } from 'react'; -import Downshift from 'downshift'; +import { useCombobox } from 'downshift'; import { useIntl } from 'react-intl'; import type { DurationInputArg1 } from 'moment'; import Constraints from '@commercetools-uikit/constraints'; @@ -145,7 +145,6 @@ export type TDateInput = { const DateInput = (props: TDateInput) => { const intl = useIntl(); const [calendarDate, setCalendarDate] = useState(props.value || getToday()); - const [suggestedItems, setSuggestedItems] = useState([]); const [highlightedIndex, setHighlightedIndex] = useState< number | null | undefined >(props.value === '' ? null : getDateInMonth(props.value) - 1); @@ -196,7 +195,7 @@ const DateInput = (props: TDateInput) => { const showToday = () => { const today = getToday(); setCalendarDate(today); - setHighlightedIndex(suggestedItems.length + getDateInMonth(today) - 1); + setHighlightedIndex(getDateInMonth(today) - 1); inputRef.current?.focus(); }; @@ -206,243 +205,234 @@ const DateInput = (props: TDateInput) => { setHighlightedIndex(dayToHighlight); }; + const calendarItems = createCalendarItems(calendarDate); + const paddingDayCount = getPaddingDayCount(calendarDate, intl.locale); + const paddingDays = Array(paddingDayCount).fill(undefined); + const weekdays = getWeekdayNames(intl.locale); + const today = getToday(); + + const itemToString = createItemToString(intl.locale); + + const { + getInputProps, + getMenuProps, + getItemProps, + getToggleButtonProps, + selectItem, + setInputValue: setDownshiftInputValue, + setHighlightedIndex: setDownshiftHighlightedIndex, + isOpen, + highlightedIndex: downshiftHighlightedIndex, + selectedItem, + inputValue, + } = useCombobox({ + inputId: props.id, + items: calendarItems, + itemToString, + selectedItem: props.value === '' ? null : props.value, + highlightedIndex: highlightedIndex ?? -1, + isItemDisabled: (item) => + !getIsDateInRange(item, props.minValue, props.maxValue), + onHighlightedIndexChange: ({ highlightedIndex: newIndex }) => { + if (newIndex !== undefined && newIndex !== -1) { + setHighlightedIndex(newIndex); + } + }, + onSelectedItemChange: ({ selectedItem: newItem }) => { + handleChange(newItem ?? null); + }, + onStateChange: (changes) => { + if (changes.hasOwnProperty('inputValue')) { + if (changes.type === useCombobox.stateChangeTypes.InputChange) { + const date = parseInputToDate(changes.inputValue ?? '', intl.locale); + if (date === '') { + setHighlightedIndex(null); + } else { + if (getIsDateInRange(date, props.minValue, props.maxValue)) { + setHighlightedIndex(getDateInMonth(date) - 1); + } + setCalendarDate(date); + } + } else { + // input changed because user selected a date + setHighlightedIndex(null); + } + } else if ( + changes.hasOwnProperty('highlightedIndex') && + changes.highlightedIndex !== undefined && + changes.highlightedIndex !== -1 + ) { + setHighlightedIndex(changes.highlightedIndex); + } + }, + }); + /** * If the user manually enters a value in the text-input field, * attempt to parse the value and emit it to the consumer if it's valid and in range. */ const onInputBlur = (event: TCustomEvent) => { - const inputValue = event.target.value || ''; - const date = parseInputToDate(inputValue, intl.locale); + const value = event.target.value || ''; + const date = parseInputToDate(value, intl.locale); const inRange = getIsDateInRange(date, props.minValue, props.maxValue); - if (inputValue.length === 0) emit(inputValue); - if (!date || !inRange) return; + if (value.length === 0) { + emit(value); + return; + } + if (!date || !inRange) { + // Reset the input to show the currently selected item when input is invalid + setDownshiftInputValue(itemToString(props.value || null)); + return; + } emit(date); }; + const shouldShowCalendar = + (isOpen && !props.isDisabled && !props.isReadOnly) || + (appearance === 'filter' && !props.isDisabled && !props.isReadOnly); + return ( - { - if (changes.hasOwnProperty('inputValue')) { - // input changed because user typed - if (changes.type === Downshift.stateChangeTypes.changeInput) { - const date = parseInputToDate(changes.inputValue, intl.locale); - if (date === '') { - setSuggestedItems([]); - setHighlightedIndex(null); - } else { - setSuggestedItems([date]); - if (getIsDateInRange(date, props.minValue, props.maxValue)) { - setHighlightedIndex(getDateInMonth(date) - 1); +
+ and the . + 'aria-labelledby': undefined, + name: props.name, + placeholder: + typeof props.placeholder === 'string' + ? props.placeholder + : getLocalizedDateTimeFormatPattern(intl.locale), + onMouseEnter: () => { + // we remove the highlight so that the user can use the + // arrow keys to move the cursor when hovering + if (isOpen) setDownshiftHighlightedIndex(-1); + }, + onKeyDown: (event) => { + if (props.isReadOnly) { + preventDownshiftDefault(event); + return; + } + if (event.key === 'Enter' && inputValue?.trim() === '') { + selectItem(null); + setDownshiftInputValue(''); + } + // ArrowDown + if (event.key === 'ArrowDown') { + const nextDayToHighlight = getNextDay( + calendarItems[Number(highlightedIndex)] + ); + if ( + !getIsDateInRange( + nextDayToHighlight, + props.minValue, + props.maxValue + ) + ) { + // if the date to highlight is disabled + // then do nothing + preventDownshiftDefault(event); + return; + } + if (Number(highlightedIndex) + 1 >= calendarItems.length) { + // if it's the end of the month + // then bypass normal arrow navigation + preventDownshiftDefault(event); + // then jump to start of next month + jumpMonth(1, 0); } - setCalendarDate(date); } - } else { - // input changed because user selected a date - setSuggestedItems([]); - setHighlightedIndex(null); - } - /** - * Asides the inputValue, we also have other ways to enter calendar inputs like the mouse move event to enter calender values. - * We check the downshift changes property to be sure it has highlightedIndex That is not null before updating it, - * otherwise it may override the initially set highlightedIndex from the inputValue and set it to null. - */ - } else if (changes.hasOwnProperty('highlightedIndex')) { - setHighlightedIndex(changes.highlightedIndex); - } - }} - > - {({ - getInputProps, - getMenuProps, - getItemProps, - getToggleButtonProps, - clearSelection, - highlightedIndex: downshiftHighlightedIndex, - openMenu, - setHighlightedIndex: setDownshiftHighlightedIndex, - selectedItem, - isOpen, - inputValue, - }) => { - const calendarItems = createCalendarItems(calendarDate); - - const paddingDayCount = getPaddingDayCount(calendarDate, intl.locale); - const paddingDays = Array(paddingDayCount).fill(undefined); - - const weekdays = getWeekdayNames(intl.locale); - const today = getToday(); - - return ( -
- and the . - 'aria-labelledby': undefined, - name: props.name, - placeholder: - typeof props.placeholder === 'string' - ? props.placeholder - : getLocalizedDateTimeFormatPattern(intl.locale), - onMouseEnter: () => { - // we remove the highlight so that the user can use the - // arrow keys to move the cursor when hovering - // @ts-ignore - if (isOpen) setDownshiftHighlightedIndex(null); - }, - onKeyDown: (event) => { - if (props.isReadOnly) { - preventDownshiftDefault(event); - return; - } - if (event.key === 'Enter' && inputValue?.trim() === '') { - clearSelection(); - } - // ArrowDown - if (event.key === 'ArrowDown') { - const nextDayToHighlight = getNextDay( - calendarItems[Number(highlightedIndex)] - ); - if ( - !getIsDateInRange( - nextDayToHighlight, - props.minValue, - props.maxValue - ) - ) { - // if the date to highlight is disabled - // then do nothing - preventDownshiftDefault(event); - return; - } - if ( - Number(highlightedIndex) + 1 >= - calendarItems.length - ) { - // if it's the end of the month - // then bypass normal arrow navigation - preventDownshiftDefault(event); - // then jump to start of next month - jumpMonth(1, 0); - } - } - // ArrowUp - if (event.key === 'ArrowUp') { - const previousDay = getPreviousDay( - calendarItems[Number(highlightedIndex)] - ); - if ( - !getIsDateInRange( - previousDay, - props.minValue, - props.maxValue - ) - ) { - // if the date to highlight is disabled - // then do nothing - preventDownshiftDefault(event); - return; - } - if (Number(highlightedIndex) <= 0) { - // if it's the start of the month - // then bypass normal arrow navigation - preventDownshiftDefault(event); + // ArrowUp + if (event.key === 'ArrowUp') { + const previousDay = getPreviousDay( + calendarItems[Number(highlightedIndex)] + ); + if ( + !getIsDateInRange(previousDay, props.minValue, props.maxValue) + ) { + // if the date to highlight is disabled + // then do nothing + preventDownshiftDefault(event); + return; + } + if (Number(highlightedIndex) <= 0) { + // if it's the start of the month + // then bypass normal arrow navigation + preventDownshiftDefault(event); - const numberOfDaysOfPrevMonth = - getDaysInMonth(previousDay); - // then jump to the last day of the previous month - jumpMonth(-1, numberOfDaysOfPrevMonth - 1); - } - } - }, - onBlur: onInputBlur, - // we only do this for readOnly because the input - // doesn't ignore these events, unlike when its disabled - onClick: props.isReadOnly ? undefined : () => openMenu(), - ...filterDataAttributes(props), - })} - hasSelection={Boolean(selectedItem)} - onClear={clearSelection} - isOpen={isOpen} - isDisabled={props.isDisabled} - isReadOnly={props.isReadOnly} - isCondensed={props.isCondensed} - toggleButtonProps={getToggleButtonProps()} - hasError={props.hasError} - hasWarning={props.hasWarning} - /> - {((isOpen && !props.isDisabled && !props.isReadOnly) || - (appearance === 'filter' && - !props.isDisabled && - !props.isReadOnly)) && ( - { + selectItem(null); + setDownshiftInputValue(''); + }} + isOpen={isOpen} + isDisabled={props.isDisabled} + isReadOnly={props.isReadOnly} + isCondensed={props.isCondensed} + toggleButtonProps={getToggleButtonProps()} + hasError={props.hasError} + hasWarning={props.hasWarning} + /> + {shouldShowCalendar && ( + + jumpMonth(-1)} + onTodayClick={showToday} + onNextMonthClick={() => jumpMonth(1)} + onPrevYearClick={() => jumpMonth(-12)} + onNextYearClick={() => jumpMonth(12)} + /> + + {weekdays.map((weekday) => ( + + {weekday} + + ))} + {paddingDays.map((_, index) => ( + + ))} + {calendarItems.map((item, index) => ( + { + setDownshiftHighlightedIndex(-1); + }, + })} + isHighlighted={index === downshiftHighlightedIndex} + isSelected={isSameDay(item, props.value)} > - jumpMonth(-1)} - onTodayClick={showToday} - onNextMonthClick={() => jumpMonth(1)} - onPrevYearClick={() => jumpMonth(-12)} - onNextYearClick={() => jumpMonth(12)} - /> - - {weekdays.map((weekday) => ( - - {weekday} - - ))} - {paddingDays.map((_, index) => ( - - ))} - {calendarItems.map((item, index) => ( - { - // @ts-ignore - setDownshiftHighlightedIndex(null); - }, - })} - isHighlighted={index === downshiftHighlightedIndex} - isSelected={isSameDay(item, props.value)} - > - {getCalendarDayLabel(item)} - - ))} - - - )} -
- ); - }} - + {getCalendarDayLabel(item)} + + ))} + + + )} +
); }; diff --git a/packages/components/inputs/date-range-input/src/date-range-input.tsx b/packages/components/inputs/date-range-input/src/date-range-input.tsx index 1469319fc3..23d3deca11 100644 --- a/packages/components/inputs/date-range-input/src/date-range-input.tsx +++ b/packages/components/inputs/date-range-input/src/date-range-input.tsx @@ -1,6 +1,6 @@ -import { createRef, Component, type KeyboardEvent } from 'react'; -import Downshift from 'downshift'; -import { injectIntl, type WrappedComponentProps } from 'react-intl'; +import { useRef, useCallback, useState, type KeyboardEvent } from 'react'; +import { useCombobox } from 'downshift'; +import { useIntl } from 'react-intl'; import type { DurationInputArg1, MomentInput } from 'moment'; import Constraints from '@commercetools-uikit/constraints'; import { filterDataAttributes } from '@commercetools-uikit/utils'; @@ -197,437 +197,407 @@ export type TDateRangeInputProps = { * Filter appearance removes borders and box shadows, and calendar is always open. */ appearance?: 'default' | 'filter'; -} & WrappedComponentProps; - -type TDateRangeInputState = { - calendarDate?: MomentInput; - suggestedItems: MomentInput[]; - startDate?: MomentInput; - highlightedIndex?: number | null; - isOpen?: boolean; - inputValue?: MomentInput; - prevValue: MomentInput[]; - prevLocale?: string; }; -class DateRangeInput extends Component< - TDateRangeInputProps, - TDateRangeInputState -> { - static displayName = 'DateRangeInput'; - static defaultProps: Pick = { - isClearable: true, - }; - static isEmpty = (range: number[]) => range.length === 0; - static getDerivedStateFromProps( - props: TDateRangeInputProps, - state: TDateRangeInputState +const DateRangeInput = ({ + isClearable = true, + ...props +}: TDateRangeInputProps) => { + const intl = useIntl(); + + const [calendarDate, setCalendarDate] = useState( + props.value.length === 2 ? props.value[0] : getToday() + ); + const [suggestedItems, setSuggestedItems] = useState([]); + const [startDate, setStartDate] = useState(null); + const [highlightedIndex, setHighlightedIndex] = useState(null); + const [isOpen, setIsOpen] = useState(false); + const [inputValue, setInputValue] = useState( + formatRange(props.value, intl.locale) + ); + + // Sync inputValue when props.value or locale changes (replaces getDerivedStateFromProps) + const prevValueRef = useRef(props.value); + const prevLocaleRef = useRef(intl.locale); + if ( + !isSameRange(props.value, prevValueRef.current) || + intl.locale !== prevLocaleRef.current ) { - // We need to update the input value string in case so that is is formatted - // according to the locale and holds the current value in case the value - // changes or when the locale changes - const shouldUpdateInputValue = - !isSameRange(props.value, state.prevValue) || - props.intl.locale !== state.prevLocale; - - if (!shouldUpdateInputValue) return null; - - return { - prevLocale: props.intl.locale, - // This is not the input value but the actual value passed to - // DateRangeInput - prevValue: props.value, - inputValue: formatRange(props.value, props.intl.locale), - }; + prevValueRef.current = props.value; + prevLocaleRef.current = intl.locale; + const newInputValue = formatRange(props.value, intl.locale); + if (newInputValue !== inputValue) { + setInputValue(newInputValue); + } } - inputRef = createRef(); - - state = { - calendarDate: - this.props.value.length === 2 ? this.props.value[0] : getToday(), - suggestedItems: [], - startDate: null, - highlightedIndex: null, - isOpen: false, - inputValue: formatRange(this.props.value, this.props.intl.locale), - prevValue: this.props.value, - prevLocale: this.props.intl.locale, - }; - jumpMonth = (amount: DurationInputArg1, dayToHighlight = 0) => { - this.setState((prevState) => { - const nextDate = changeMonth(prevState.calendarDate, amount); - return { calendarDate: nextDate, highlightedIndex: dayToHighlight }; - }); - }; - showToday = () => { + const inputRef = useRef(null); + + const appearance = props.appearance || 'default'; + + const emit = useCallback( + (unsortedRange: MomentInput[]) => { + props.onChange?.({ + target: { + id: props.id, + name: props.name, + value: unsortedRange.sort(), + }, + }); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [props.onChange, props.id, props.name] + ); + + const jumpMonth = useCallback( + (amount: DurationInputArg1, dayToHighlight = 0) => { + setCalendarDate((prevDate) => { + return changeMonth(prevDate, amount); + }); + setHighlightedIndex(dayToHighlight); + }, + [] + ); + + const showToday = useCallback(() => { const today = getToday(); - this.setState( - (prevState) => ({ - calendarDate: today, - highlightedIndex: - prevState.suggestedItems.length + getDateInMonth(today) - 1, - }), - () => this.inputRef.current?.focus() - ); - }; - handleBlur = () => { - if (this.props.onBlur) - this.props.onBlur({ + setCalendarDate(today); + setHighlightedIndex(suggestedItems.length + getDateInMonth(today) - 1); + inputRef.current?.focus(); + }, [suggestedItems.length]); + + const handleBlur = useCallback(() => { + if (props.onBlur) + props.onBlur({ target: { - id: this.props.id, - name: this.props.name, + id: props.id, + name: props.name, }, }); - }; - emit = (unsortedRange: MomentInput[]) => { - this.props.onChange?.({ - target: { - id: this.props.id, - name: this.props.name, - value: unsortedRange.sort(), - }, - }); - }; - render() { - const appearance = this.props.appearance || 'default'; - - return ( - - { - // only attempt to parse input when the user typed into the input - // field - // @ts-ignore - if (changes.type !== Downshift.stateChangeTypes.changeInput) return; - - this.setState(() => { - const parsedRange = parseRangeText( - inputValue, - this.props.intl.locale + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props.onBlur, props.id, props.name]); + + const calendarItems = createCalendarItems(calendarDate); + const allItems = [...suggestedItems, ...calendarItems]; + + // Ref to track whether we want to intercept InputKeyDownEnter (for keyboard clear) + const shouldInterceptEnterRef = useRef(false); + + const { + getInputProps, + getMenuProps, + getItemProps, + getToggleButtonProps, + selectItem, + setInputValue: setDownshiftInputValue, + setHighlightedIndex: setDownshiftHighlightedIndex, + openMenu, + isOpen: downshiftIsOpen, + highlightedIndex: downshiftHighlightedIndex, + inputValue: downshiftInputValue, + } = useCombobox({ + inputId: props.id, + items: allItems, + itemToString: createItemRangeToString(intl.locale), + selectedItem: null, + isOpen: isOpen, + inputValue: inputValue, + highlightedIndex: highlightedIndex ?? -1, + isItemDisabled: () => Boolean(props.isDisabled), + stateReducer: (state, { type, changes }) => { + // When we want to intercept Enter (to clear with keyboard), prevent downshift + // from processing it (it would otherwise try to select a highlighted item or close). + if ( + type === useCombobox.stateChangeTypes.InputKeyDownEnter && + shouldInterceptEnterRef.current + ) { + // Reset the intercept flag and return current state unchanged + shouldInterceptEnterRef.current = false; + return state; + } + return changes; + }, + onIsOpenChange: ({ isOpen: newIsOpen }) => { + setIsOpen(newIsOpen ?? false); + }, + onInputValueChange: ({ inputValue: newInputValue, type }) => { + // only attempt to parse input when the user typed into the input field + if (type !== useCombobox.stateChangeTypes.InputChange) return; + const parsedRange = parseRangeText(newInputValue ?? '', intl.locale); + if (parsedRange.length === 0) { + setSuggestedItems([]); + setHighlightedIndex(null); + setInputValue(newInputValue ?? ''); + setStartDate(null); + } else if (parsedRange.length === 1) { + const calDate = parsedRange[0] as MomentInput; + setSuggestedItems([]); + setHighlightedIndex(getDateInMonth(calDate) - 1); + setInputValue(newInputValue ?? ''); + setStartDate(parsedRange[0] as MomentInput); + setCalendarDate(calDate); + } else if (parsedRange.length === 2) { + const calDate = parsedRange[1] as MomentInput; + setSuggestedItems([]); + setHighlightedIndex(getDateInMonth(calDate) - 1); + setInputValue(newInputValue ?? ''); + setStartDate(parsedRange[0] as MomentInput); + setCalendarDate(calDate); + } + }, + onSelectedItemChange: ({ selectedItem: newItem }) => { + if (startDate && newItem) { + emit([startDate, newItem]); + } else { + emit([]); + } + }, + onStateChange: (changes) => { + if ( + changes.type === useCombobox.stateChangeTypes.MenuMouseLeave || + changes.type === useCombobox.stateChangeTypes.InputBlur + ) { + setHighlightedIndex(null); + setIsOpen(false); + setInputValue(formatRange(props.value, intl.locale)); + return; + } + + if (changes.hasOwnProperty('selectedItem')) { + const hasStartedRangeSelection = Boolean( + !startDate && changes.selectedItem + ); + const hasFinishedRangeSelection = Boolean( + startDate && changes.selectedItem + ); + + setHighlightedIndex(highlightedIndex); + setStartDate(startDate ? null : (changes.selectedItem as MomentInput)); + if (changes.selectedItem) { + setCalendarDate(changes.selectedItem as MomentInput); + } + setIsOpen(!hasFinishedRangeSelection); + setInputValue( + (() => { + if (hasFinishedRangeSelection) { + return formatRange( + [startDate, changes.selectedItem as MomentInput], + intl.locale ); - if (parsedRange.length === 0) - return { - suggestedItems: [], - highlightedIndex: null, - inputValue, - startDate: null, - }; - if (parsedRange.length === 1) { - const calendarDate = parsedRange[0]; - return { - suggestedItems: [], - highlightedIndex: getDateInMonth(calendarDate) - 1, - inputValue, - startDate: parsedRange[0], - calendarDate, - }; - } - if (parsedRange.length === 2) { - const calendarDate = parsedRange[1]; - return { - suggestedItems: [], - highlightedIndex: getDateInMonth(calendarDate) - 1, - inputValue, - startDate: parsedRange[0], - calendarDate, - }; + } + if (hasStartedRangeSelection) { + return formatRange( + [changes.selectedItem as MomentInput], + intl.locale + ); + } + return ''; + })() + ); + return; + } + + if (changes.hasOwnProperty('isOpen')) { + setIsOpen(changes.isOpen ?? false); + setHighlightedIndex( + changes.highlightedIndex !== undefined && + changes.highlightedIndex !== -1 + ? changes.highlightedIndex + : null + ); + if (changes.inputValue !== undefined) { + setInputValue(changes.inputValue); + } + setStartDate(null); + setCalendarDate(props.value.length === 2 ? props.value[0] : getToday()); + return; + } + + if (changes.hasOwnProperty('highlightedIndex')) { + setHighlightedIndex( + changes.highlightedIndex !== undefined && + changes.highlightedIndex !== -1 + ? changes.highlightedIndex + : null + ); + } + }, + }); + + const paddingDayCount = getPaddingDayCount(calendarDate, intl.locale); + const paddingDays = Array(paddingDayCount).fill(undefined); + const weekdays = getWeekdayNames(intl.locale); + const today = getToday(); + + const shouldShowCalendar = + (downshiftIsOpen && !props.isDisabled) || + (appearance === 'filter' && !props.isDisabled && !props.isReadOnly); + + return ( + +
+ and the . + 'aria-labelledby': undefined, + name: props.name, + placeholder: + typeof props.placeholder === 'string' + ? props.placeholder + : `${getLocalizedDateTimeFormatPattern( + intl.locale + )} - ${getLocalizedDateTimeFormatPattern(intl.locale)}`, + onMouseEnter: () => { + // we remove the highlight so that the user can use the + // arrow keys to move the cursor when hovering + if (downshiftIsOpen) setDownshiftHighlightedIndex(-1); + }, + onKeyDown: (event) => { + if (props.isReadOnly) { + preventDownshiftDefault(event as TPreventDownshiftDefaultEvent); + return; } - return null; - }); - }} - onStateChange={(changes) => { - this.setState((prevState) => { if ( - changes.type === Downshift.stateChangeTypes.mouseUp || - changes.type === Downshift.stateChangeTypes.blurInput + event.key === 'Enter' && + downshiftInputValue?.trim() === '' && + // do not clear value when user presses Enter to + // select the end date (so only clear when there is no startDate) + !startDate && + isClearable ) { - return { - highlightedIndex: null, - isOpen: false, - inputValue: formatRange( - this.props.value, - this.props.intl.locale - ), - }; + // Signal to stateReducer to intercept/swallow the Enter key in downshift + shouldInterceptEnterRef.current = true; + // Also use preventDownshiftDefault as a belt-and-suspenders approach + preventDownshiftDefault(event as TPreventDownshiftDefaultEvent); + // Clear state (keep menu open to match original behavior) + setInputValue(''); + setStartDate(null); + setHighlightedIndex(null); + emit([]); } - - if (changes.hasOwnProperty('selectedItem')) { - const hasStartedRangeSelection = Boolean( - !prevState.startDate && changes.selectedItem - ); - const hasFinishedRangeSelection = Boolean( - prevState.startDate && changes.selectedItem - ); - - return { - highlightedIndex: prevState.highlightedIndex, - startDate: prevState.startDate ? null : changes.selectedItem, - calendarDate: changes.selectedItem, - isOpen: !hasFinishedRangeSelection, - inputValue: (() => { - if (hasFinishedRangeSelection) { - return formatRange( - [prevState.startDate, changes.selectedItem], - this.props.intl.locale - ); - } - if (hasStartedRangeSelection) { - return formatRange( - [changes.selectedItem], - this.props.intl.locale - ); - } - return ''; - })(), - }; + // ArrowDown + if (event.key === 'ArrowDown') { + if ( + (downshiftHighlightedIndex as number) + 1 >= + calendarItems.length + ) { + // if it's the end of the month + // then bypass normal arrow navigation + preventDownshiftDefault( + event as TPreventDownshiftDefaultEvent + ); + // then jump to start of next month + jumpMonth(1, 0); + } } - - if (changes.hasOwnProperty('isOpen')) { - return { - isOpen: changes.isOpen, - highlightedIndex: changes.highlightedIndex || null, - inputValue: changes.inputValue || prevState.inputValue, - // Reset range selection progress when menu opens/closes - startDate: null, - // Ensure calendar opens on selected date. - // Open on the current day as a fallback. - calendarDate: - this.props.value.length === 2 - ? this.props.value[0] - : getToday(), - }; - } - - if (changes.hasOwnProperty('highlightedIndex')) { - return { highlightedIndex: changes.highlightedIndex }; + // ArrowUp + if (event.key === 'ArrowUp') { + const previousDay = getPreviousDay( + calendarItems[downshiftHighlightedIndex as number] + ); + if ((downshiftHighlightedIndex as number) <= 0) { + // if it's the start of the month + // then bypass normal arrow navigation + preventDownshiftDefault( + event as TPreventDownshiftDefaultEvent + ); + + const numberOfDaysOfPrevMonth = getDaysInMonth(previousDay); + // then jump to the last day of the previous month + jumpMonth(-1, numberOfDaysOfPrevMonth - 1); + } } - - return null; - }); - }} - onChange={(selectedItem) => { - if (this.state.startDate && selectedItem) { - this.emit([this.state.startDate, selectedItem]); - } else { - this.emit([]); - } + }, + // we only do this for readOnly because the input + // doesn't ignore these events, unlike when its disabled + onClick: props.isReadOnly ? undefined : () => openMenu(), + ...filterDataAttributes(props), + })} + hasSelection={props.value.length === 2} + isClearable={isClearable} + onClear={() => { + setStartDate(null); + emit([]); + selectItem(null); + setDownshiftInputValue(''); }} - isOpen={this.state.isOpen} - > - {({ - getInputProps, - getMenuProps, - getItemProps, - getToggleButtonProps, - - clearSelection, - - highlightedIndex, - openMenu, - setHighlightedIndex, - isOpen, - inputValue, - }) => { - const calendarItems = createCalendarItems(this.state.calendarDate); - const allItems = [...this.state.suggestedItems, ...calendarItems]; - - const paddingDayCount = getPaddingDayCount( - this.state.calendarDate, - this.props.intl.locale - ); - const paddingDays = Array(paddingDayCount).fill(undefined); - - const weekdays = getWeekdayNames(this.props.intl.locale); - - const today = getToday(); - - return ( -
- and the . - 'aria-labelledby': undefined, - name: this.props.name, - placeholder: - typeof this.props.placeholder === 'string' - ? this.props.placeholder - : `${getLocalizedDateTimeFormatPattern( - this.props.intl.locale - )} - ${getLocalizedDateTimeFormatPattern( - this.props.intl.locale - )}`, - onMouseEnter: () => { - // we remove the highlight so that the user can use the - // arrow keys to move the cursor when hovering - // @ts-ignore - if (isOpen) setHighlightedIndex(null); - }, - onKeyDown: (event) => { - if (this.props.isReadOnly) { - preventDownshiftDefault( - event as TPreventDownshiftDefaultEvent - ); - return; - } - if ( - event.key === 'Enter' && - inputValue?.trim() === '' && - // do not clear value when user presses Enter to - // select the end date (so only clear when there is no - // startDate) - !this.state.startDate && - this.props.isClearable - ) { - clearSelection(); - this.emit([]); - } - // ArrowDown - if (event.key === 'ArrowDown') { - if ( - (highlightedIndex as number) + 1 >= - calendarItems.length - ) { - // if it's the end of the month - // then bypass normal arrow navigation - preventDownshiftDefault( - event as TPreventDownshiftDefaultEvent - ); - // then jump to start of next month - this.jumpMonth(1, 0); - } - } - // ArrowUp - if (event.key === 'ArrowUp') { - const previousDay = getPreviousDay( - calendarItems[highlightedIndex as number] - ); - - if ((highlightedIndex as number) <= 0) { - // if it's the start of the month - // then bypass normal arrow navigation - preventDownshiftDefault( - event as TPreventDownshiftDefaultEvent - ); - - const numberOfDaysOfPrevMonth = - getDaysInMonth(previousDay); - // then jump to the last day of the previous month - this.jumpMonth(-1, numberOfDaysOfPrevMonth - 1); - } - } - }, - // we only do this for readOnly because the input - // doesn't ignore these events, unlike when its disabled - onClick: this.props.isReadOnly - ? undefined - : () => openMenu(), - ...filterDataAttributes(this.props), - })} - hasSelection={this.props.value.length === 2} - isClearable={this.props.isClearable} - onClear={() => { - this.setState({ startDate: null }); - this.emit([]); - clearSelection(); - }} - isOpen={isOpen} - isDisabled={this.props.isDisabled} - isReadOnly={this.props.isReadOnly} - isCondensed={this.props.isCondensed} - toggleButtonProps={getToggleButtonProps()} - hasError={this.props.hasError} - hasWarning={this.props.hasWarning} - /> - {((isOpen && !this.props.isDisabled) || - (appearance === 'filter' && - !this.props.isDisabled && - !this.props.isReadOnly)) && ( - + {shouldShowCalendar && ( + + jumpMonth(-1)} + onTodayClick={showToday} + onNextMonthClick={() => jumpMonth(1)} + onPrevYearClick={() => jumpMonth(-12)} + onNextYearClick={() => jumpMonth(12)} + /> + + {weekdays.map((weekday) => ( + + {weekday} + + ))} + {paddingDays.map((_, index) => ( + + ))} + {calendarItems.map((item, index) => { + const isHighlighted = + suggestedItems.length + index === downshiftHighlightedIndex; + const { isRangeStart, isRangeBetween, isRangeEnd } = getRange({ + item, + value: props.value, + startDate: startDate, + highlightedItem: allItems[highlightedIndex || 0], + }); + return ( + { + setDownshiftHighlightedIndex(-1); + }, + })} + isHighlighted={isHighlighted} + isRangeStart={isRangeStart} + isRangeBetween={isRangeBetween} + isRangeEnd={isRangeEnd} > - this.jumpMonth(-1)} - onTodayClick={this.showToday} - onNextMonthClick={() => this.jumpMonth(1)} - onPrevYearClick={() => this.jumpMonth(-12)} - onNextYearClick={() => this.jumpMonth(12)} - /> - - {weekdays.map((weekday) => ( - - {weekday} - - ))} - {paddingDays.map((_, index) => ( - - ))} - {calendarItems.map((item, index) => { - const isHighlighted = - this.state.suggestedItems.length + index === - highlightedIndex; - const { isRangeStart, isRangeBetween, isRangeEnd } = - getRange({ - item, - value: this.props.value, - startDate: this.state.startDate, - highlightedItem: - allItems[this.state.highlightedIndex || 0], - }); - return ( - { - // @ts-ignore - setHighlightedIndex(null); - }, - })} - isHighlighted={isHighlighted} - isRangeStart={isRangeStart} - isRangeBetween={isRangeBetween} - isRangeEnd={isRangeEnd} - > - {getCalendarDayLabel(item)} - - ); - })} - - - )} -
- ); - }} - - - ); - } -} + {getCalendarDayLabel(item)} + + ); + })} + + + )} +
+
+ ); +}; + +DateRangeInput.displayName = 'DateRangeInput'; +DateRangeInput.isEmpty = (range: number[]) => range.length === 0; +DateRangeInput.defaultProps = { isClearable: true }; -export default injectIntl(DateRangeInput); +export default DateRangeInput; diff --git a/packages/components/inputs/date-time-input/src/date-time-input.tsx b/packages/components/inputs/date-time-input/src/date-time-input.tsx index db156878cd..2af2711940 100644 --- a/packages/components/inputs/date-time-input/src/date-time-input.tsx +++ b/packages/components/inputs/date-time-input/src/date-time-input.tsx @@ -1,15 +1,15 @@ import { - createRef, - Component, + useRef, + useCallback, + useState, type FocusEventHandler, type MouseEventHandler, type KeyboardEvent, - type RefObject, type FocusEvent, } from 'react'; -import type { DurationInputArg1, MomentInput } from 'moment'; -import Downshift from 'downshift'; -import { injectIntl, type WrappedComponentProps } from 'react-intl'; +import type { DurationInputArg1 } from 'moment'; +import { useCombobox } from 'downshift'; +import { useIntl } from 'react-intl'; import Constraints from '@commercetools-uikit/constraints'; import { filterDataAttributes, @@ -47,13 +47,6 @@ import { } from '@commercetools-uikit/calendar-utils'; import TimeInput from './time-input'; -const activationTypes = [ - Downshift.stateChangeTypes.keyDownEnter, - Downshift.stateChangeTypes.clickItem, -]; - -type TActivationTypes = (typeof activationTypes)[number]; - type TKeyboardEventWithPreventDefault = KeyboardEvent & { nativeEvent: KeyboardEvent['nativeEvent'] & { @@ -75,22 +68,6 @@ const preventDownshiftDefault = (event: TPreventDownshiftDefaultEvent) => { event.nativeEvent.preventDownshiftDefault = true; }; -// This keeps the menu open when the user focuses the time input (thereby -// blurring the regular input/toggle button) -const createBlurHandler = - (timeInputRef: RefObject, cb: () => void = () => {}) => - ( - event: TFocusEventWithPreventDefault - ) => { - event.persist(); - - if (event.relatedTarget === timeInputRef.current) { - preventDownshiftDefault(event); - } - - cb(); - }; - type TCustomEvent = { target: { id?: string; @@ -143,15 +120,15 @@ export type TDateTimeInputProps = { onBlur?: (event: TCustomEvent) => void; /** * Specifies the time zone in which the calendar and selected values are shown. It also influences how entered dates and times are parsed. - * Get list of timezone with `moment.tz.names()` [See moment docs](https://momentjs.com/timezone/docs/#/data-loading/getting-zone-names/) + * Get list of timezone with moment.tz.names() */ timeZone: string; /** - * Used as the HTML `id` attribute. + * Used as the HTML id attribute. */ id?: string; /** - * Used as the HTML `name` attribute. + * Used as the HTML name attribute. */ name?: string; /** @@ -188,481 +165,427 @@ export type TDateTimeInputProps = { * Filter appearance removes borders and box shadows, and calendar is always open. */ appearance?: 'default' | 'filter'; -} & WrappedComponentProps; - -type TDateTimeInputState = { - calendarDate?: string; - suggestedItems?: string[]; - highlightedIndex?: number | null; - timeString?: string; - startDate?: MomentInput; - inputValue?: MomentInput; }; -class DateTimeInput extends Component< - TDateTimeInputProps, - TDateTimeInputState -> { - static displayName = 'DateTimeInput'; +type TActivationTypes = + | typeof useCombobox.stateChangeTypes.InputKeyDownEnter + | typeof useCombobox.stateChangeTypes.ItemClick; - inputRef = createRef(); - timeInputRef = createRef(); - state = { - calendarDate: getToday(this.props.timeZone), - suggestedItems: [], - highlightedIndex: - this.props.value === '' - ? null - : getDateInMonth(this.props.value, this.props.timeZone) - 1, - timeString: this.props.defaultDaySelectionTime - ? formatDefaultTime( - this.props.defaultDaySelectionTime, - this.props.intl.locale - ) - : '', - }; +const activationTypes: TActivationTypes[] = [ + useCombobox.stateChangeTypes.InputKeyDownEnter, + useCombobox.stateChangeTypes.ItemClick, +]; - jumpMonths = (amount: DurationInputArg1, dayToHighlight = 0) => { - this.setState((prevState) => { - const nextDate = changeMonth( - prevState.calendarDate, - this.props.timeZone, - amount - ); - return { calendarDate: nextDate, highlightedIndex: dayToHighlight }; - }); - }; - showToday = () => { - const today = getToday(this.props.timeZone); - this.setState( - (prevState) => ({ - calendarDate: today, - highlightedIndex: - (prevState.suggestedItems || []).length + - getDateInMonth(today, this.props.timeZone) - - 1, - }), - () => this.inputRef.current?.focus() +const DateTimeInput = (props: TDateTimeInputProps) => { + const intl = useIntl(); + + const inputRef = useRef(null); + const timeInputRef = useRef(null); + + const itemToString = createItemDateTimeToString( + intl.locale, + props.timeZone + ) as (item: string | null) => string; + + const [calendarDate, setCalendarDate] = useState( + getToday(props.timeZone) + ); + const [suggestedItems, setSuggestedItems] = useState([]); + const [highlightedIndex, setHighlightedIndex] = useState( + props.value === '' ? null : getDateInMonth(props.value, props.timeZone) - 1 + ); + const [timeString, setTimeString] = useState( + props.defaultDaySelectionTime + ? formatDefaultTime(props.defaultDaySelectionTime, intl.locale) + : '' + ); + + if (!props.isReadOnly) { + warning( + typeof props.onChange === 'function', + 'DateTimeInput: onChange is required when input is not read only.' ); - }; - handleBlur = () => { - if (this.props.onBlur) - this.props.onBlur({ - target: { - id: this.props.id, - name: this.props.name, - }, - }); - }; - handleTimeChange = (event: TCustomEvent) => { - const parsedTime = parseTime(event.target.value); + } - this.setState({ timeString: event.target.value }); + const appearance = props.appearance || 'default'; - // We can't update the parent when there is no value - if (this.props.value === '') return; + const emit = useCallback( + (value: string | null) => + props.onChange?.({ + target: { + id: props.id, + name: props.name, + value: value || '', + }, + }), + [props.onChange, props.id, props.name] + ); - let date = getStartOf(this.props.value, this.props.timeZone); - if (parsedTime) { - date = changeTime(date, this.props.timeZone, parsedTime); - } - this.emit(date); - }; - emit = (value: string | null) => - this.props.onChange?.({ - target: { - id: this.props.id, - name: this.props.name, - // when cleared the value is null, but we always want it to be an - // empty string when there is no value. - value: value || '', - }, - }); - render() { - if (!this.props.isReadOnly) { - warning( - typeof this.props.onChange === 'function', - 'DateTimeInput: `onChange` is required when input is not read only.' + const handleBlur = useCallback(() => { + if (props.onBlur) + props.onBlur({ + target: { + id: props.id, + name: props.name, + }, + }); + }, [props.onBlur, props.id, props.name]); + + const handleTimeChange = useCallback( + (event: TCustomEvent) => { + const parsedTime = parseTime(event.target.value); + setTimeString(event.target.value ?? ''); + if (props.value === '') return; + let date = getStartOf(props.value, props.timeZone); + if (parsedTime) { + date = changeTime(date, props.timeZone, parsedTime); + } + emit(date); + }, + [props.value, props.timeZone, emit] + ); + + const jumpMonths = useCallback( + (amount: DurationInputArg1, dayToHighlight = 0) => { + setCalendarDate((prevDate) => + changeMonth(prevDate, props.timeZone, amount) ); - } - - const appearance = this.props.appearance || 'default'; - - return ( - - string - } - selectedItem={this.props.value === '' ? null : this.props.value} - highlightedIndex={this.state.highlightedIndex} - onChange={this.emit} - stateReducer={(_, changes) => { - if (activationTypes.includes(changes.type as TActivationTypes)) { - return { ...changes, isOpen: true }; - } - - return changes; - }} - onStateChange={(changes) => { - this.setState( - (prevState) => { + setHighlightedIndex(dayToHighlight); + }, + [props.timeZone] + ); + + const showToday = useCallback(() => { + const today = getToday(props.timeZone); + setCalendarDate(today); + setHighlightedIndex( + suggestedItems.length + getDateInMonth(today, props.timeZone) - 1 + ); + inputRef.current?.focus(); + }, [props.timeZone, suggestedItems.length]); + + const calendarItems = createCalendarItems( + calendarDate, + timeString, + props.timeZone + ); + const allItems = [...suggestedItems, ...calendarItems]; + + const { + getInputProps, + getMenuProps, + getItemProps, + getToggleButtonProps, + selectItem, + setInputValue: setDownshiftInputValue, + setHighlightedIndex: setDownshiftHighlightedIndex, + closeMenu, + isOpen, + highlightedIndex: downshiftHighlightedIndex, + selectedItem, + } = useCombobox({ + inputId: props.id, + items: allItems, + itemToString, + selectedItem: props.value === '' ? null : props.value, + highlightedIndex: highlightedIndex ?? -1, + stateReducer: (state, { type, changes }) => { + if ( + type === useCombobox.stateChangeTypes.InputBlur && + document.activeElement === timeInputRef.current + ) { + return { ...changes, isOpen: state.isOpen }; + } + if (activationTypes.includes(type as TActivationTypes)) { + return { ...changes, isOpen: true }; + } + return changes; + }, + onSelectedItemChange: ({ selectedItem: newItem }) => { + emit(newItem); + }, + onStateChange: (changes) => { + if (activationTypes.includes(changes.type as TActivationTypes)) { + setTimeString((prev) => + changes.selectedItem + ? formatTime(changes.selectedItem, intl.locale, props.timeZone) + : prev + ); + setTimeout(() => { + timeInputRef.current?.focus(); + timeInputRef.current?.setSelectionRange( + 0, + timeInputRef.current?.value?.length ?? 0 + ); + }, 0); + return; + } + + if (changes.hasOwnProperty('inputValue')) { + const newSuggestedItems = createSuggestedItems( + changes.inputValue as string, + props.timeZone + ); + setSuggestedItems(newSuggestedItems); + setHighlightedIndex(newSuggestedItems.length > 0 ? 0 : null); + return; + } + + if (changes.hasOwnProperty('isOpen')) { + setTimeString( + changes.isOpen && props.value !== '' + ? formatTime(props.value, intl.locale, props.timeZone) + : timeString + ); + setCalendarDate( + props.value === '' + ? getToday(props.timeZone) + : getStartOf(props.value, props.timeZone) + ); + return; + } + + if (changes.hasOwnProperty('highlightedIndex')) { + setHighlightedIndex( + changes.highlightedIndex !== undefined && + changes.highlightedIndex !== -1 + ? changes.highlightedIndex + : null + ); + } + }, + }); + + const paddingDayCount = getPaddingDayCount( + calendarDate, + intl.locale, + props.timeZone + ); + const paddingDays = Array(paddingDayCount).fill(undefined); + const weekdays = getWeekdayNames(intl.locale); + const today = getToday(props.timeZone); + const isTimeInputVisible = Boolean(props.value) && props.value !== ''; + const shouldShowCalendar = + (isOpen && !props.isDisabled) || + (appearance === 'filter' && !props.isDisabled); + + return ( + +
+ { + if (isOpen) setDownshiftHighlightedIndex(-1); + }, + onKeyDown: ( + event: TKeyboardEventWithPreventDefault< + HTMLInputElement | HTMLButtonElement + > + ) => { + if (props.isReadOnly) { + preventDownshiftDefault(event); + return; + } + if (event.key === 'Enter' && downshiftHighlightedIndex === -1) { + preventDownshiftDefault(event); + // Use inputRef for keyDown (event.target.value unreliable on keydown) + const currentInputValue = + inputRef.current?.value ?? + (event.target as HTMLInputElement).value ?? + ''; + const parsedDate = parseInputText( + currentInputValue, + intl.locale, + props.timeZone + ); + if (!parsedDate) return; + emit(parsedDate); + closeMenu(); + } + if (event.key === 'ArrowDown') { if ( - activationTypes.includes(changes.type as TActivationTypes) + Number(downshiftHighlightedIndex) + 1 >= + calendarItems.length ) { - return { - startDate: changes.isOpen ? prevState.startDate : null, - inputValue: changes.inputValue || prevState.inputValue, - timeString: changes.selectedItem - ? formatTime( - changes.selectedItem, - this.props.intl.locale, - this.props.timeZone - ) - : prevState.timeString, - }; - } - - if (changes.hasOwnProperty('inputValue')) { - const suggestedItems = createSuggestedItems( - changes.inputValue as string, - this.props.timeZone - ); - return { - suggestedItems, - highlightedIndex: suggestedItems.length > 0 ? 0 : null, - }; + preventDownshiftDefault(event); + jumpMonths(1, 0); } - - if (changes.hasOwnProperty('isOpen')) { - return { - inputValue: changes.inputValue || prevState.inputValue, - startDate: changes.isOpen ? prevState.startDate : null, - // set time input value to time from value when menu is opened - // or to the current timeString which equals to defaultDaySelectionTime prop - timeString: - changes.isOpen && this.props.value !== '' - ? formatTime( - this.props.value, - this.props.intl.locale, - this.props.timeZone - ) - : this.state.timeString, - // ensure calendar always opens on selected item, or on - // current month when there is no selected item - calendarDate: - this.props.value === '' - ? getToday(this.props.timeZone) - : getStartOf(this.props.value, this.props.timeZone), - }; - } - - if (changes.hasOwnProperty('highlightedIndex')) { - return { highlightedIndex: changes.highlightedIndex }; - } - return null; - }, - () => { - if ( - activationTypes.includes(changes.type as TActivationTypes) - ) { - this.timeInputRef.current?.focus(); - this.timeInputRef.current?.setSelectionRange( - 0, - this.state.timeString.length + } + if (event.key === 'ArrowUp') { + const previousDay = getPreviousDay( + calendarItems[Number(downshiftHighlightedIndex)] + ); + if (Number(downshiftHighlightedIndex) <= 0) { + preventDownshiftDefault(event); + const numberOfDaysOfPrevMonth = getDaysInMonth( + previousDay, + props.timeZone ); + jumpMonths(-1, numberOfDaysOfPrevMonth - 1); } } - ); + }, + onClick: props.isReadOnly + ? (preventDownshiftDefault as unknown as MouseEventHandler) + : undefined, + onBlur: ( + event: TFocusEventWithPreventDefault + ) => { + if (event.relatedTarget === timeInputRef.current) { + preventDownshiftDefault(event); + return; + } + // Use event.target.value — more reliable than inputRef since downshift + // may have already reset inputRef's value via controlled rendering. + const currentInputValue = + (event.target as HTMLInputElement).value || ''; + const parsedDate = parseInputText( + currentInputValue, + intl.locale, + props.timeZone + ); + if (currentInputValue.length > 0 && !parsedDate) { + // Invalid input: reset to last valid formatted value + setDownshiftInputValue( + itemToString(props.value === '' ? null : props.value) + ); + return; + } + emit(parsedDate); + if (parsedDate) { + // After a valid blur, ensure display shows canonical format + setDownshiftInputValue(itemToString(parsedDate)); + } + }, + onChange: (event: TCustomEvent) => { + if (!isOpen) return; + const time = event.target.value?.split(' ')[1]; + if (!time) return; + const parsedTime = parseTime(time); + if (!parsedTime) { + setTimeString(''); + return; + } + let date = getToday(props.timeZone); + date = changeTime(date, props.timeZone, parsedTime); + setTimeString(formatTime(date, intl.locale, props.timeZone)); + }, + ...filterDataAttributes(props), + })} + hasSelection={Boolean(selectedItem)} + onClear={() => { + selectItem(null); + setDownshiftInputValue(''); }} - > - {({ - getInputProps, - getMenuProps, - getItemProps, - getToggleButtonProps, - clearSelection, - highlightedIndex, - openMenu, - closeMenu, - setHighlightedIndex, - selectedItem, - inputValue, - isOpen, - }) => { - const suggestedItems = this.state.suggestedItems; - const calendarItems = createCalendarItems( - this.state.calendarDate, - this.state.timeString, - this.props.timeZone - ); - - const paddingDayCount = getPaddingDayCount( - this.state.calendarDate, - this.props.intl.locale, - this.props.timeZone - ); - const paddingDays = Array(paddingDayCount).fill(undefined); - - const weekdays = getWeekdayNames(this.props.intl.locale); - const today = getToday(this.props.timeZone); - - const isTimeInputVisible = - Boolean(this.props.value) && this.props.value !== ''; - - return ( -
- and the . - 'aria-labelledby': undefined, - name: this.props.name, - placeholder: - typeof this.props.placeholder === 'string' - ? this.props.placeholder - : getLocalizedDateTimeFormatPattern( - this.props.intl.locale, - 'full' - ), - onMouseEnter: () => { - // we remove the highlight so that the user can use the - // arrow keys to move the cursor when hovering - if (isOpen) setHighlightedIndex(-1); - }, - onKeyDown: ( - event: TKeyboardEventWithPreventDefault< - HTMLInputElement | HTMLButtonElement - > - ) => { - if (this.props.isReadOnly) { - preventDownshiftDefault(event); - return; - } - // parse input when user presses enter on regular input, - // close menu and notify parent - if (event.key === 'Enter' && highlightedIndex === null) { - preventDownshiftDefault(event); - - const parsedDate = parseInputText( - inputValue as string, - this.props.intl.locale, - this.props.timeZone - ); - - // If there is no parsed date, don't clear and submit. Instead, give - // the user a chance to fix the value. - if (!parsedDate) return; - - this.emit(parsedDate); - - closeMenu(); - } - // ArrowDown - if (event.key === 'ArrowDown') { - if ( - Number(highlightedIndex) + 1 >= - calendarItems.length - ) { - // if it's the end of the month - // then bypass normal arrow navigation - preventDownshiftDefault(event); - // then jump to start of next month - this.jumpMonths(1, 0); - } - } - // ArrowUp - if (event.key === 'ArrowUp') { - const previousDay = getPreviousDay( - calendarItems[Number(highlightedIndex)] - ); - - if (Number(highlightedIndex) <= 0) { - // if it's the start of the month - // then bypass normal arrow navigation - preventDownshiftDefault(event); - const numberOfDaysOfPrevMonth = getDaysInMonth( - previousDay, - this.props.timeZone - ); - // then jump to the last day of the previous month - this.jumpMonths(-1, numberOfDaysOfPrevMonth - 1); - } - } - }, - onClick: this.props.isReadOnly - ? undefined - : (openMenu as unknown as MouseEventHandler), - // validate the input on blur, and emit the value if it's valid - onBlur: ( - event: TFocusEventWithPreventDefault - ) => { - createBlurHandler( - this.timeInputRef as RefObject, - () => { - const inputValue = this.inputRef.current?.value || ''; - const parsedDate = parseInputText( - inputValue, - this.props.intl.locale, - this.props.timeZone - ); - - if (inputValue.length > 0 && !parsedDate) return; - this.emit(parsedDate); - } - )(event); - }, - onChange: (event: TCustomEvent) => { - // keep timeInput and regular input in sync when user - // types into regular input - if (!isOpen) return; - - const time = event.target.value?.split(' ')[1]; - if (!time) return; - - const parsedTime = parseTime(time); - this.setState(() => { - if (!parsedTime) return { timeString: '' }; - - let date = getToday(this.props.timeZone); - if (parsedTime) { - date = changeTime( - date, - this.props.timeZone, - parsedTime - ); - } - return { - timeString: formatTime( - date, - this.props.intl.locale, - this.props.timeZone - ), - }; - }); + isOpen={isOpen} + isCondensed={props.isCondensed} + isDisabled={props.isDisabled} + isReadOnly={props.isReadOnly} + toggleButtonProps={getToggleButtonProps({ + onBlur: ( + event: TFocusEventWithPreventDefault + ) => { + if (event.relatedTarget === timeInputRef.current) { + preventDownshiftDefault(event); + } + }, + })} + hasError={props.hasError} + hasWarning={props.hasWarning} + /> + {shouldShowCalendar && ( + + jumpMonths(-1)} + onTodayClick={showToday} + onNextMonthClick={() => jumpMonths(1)} + onPrevYearClick={() => jumpMonths(-12)} + onNextYearClick={() => jumpMonths(12)} + /> + + {weekdays.map((weekday) => ( + + {weekday} + + ))} + {paddingDays.map((_, index) => ( + + ))} + {calendarItems.map((item, index) => ( + { + setDownshiftHighlightedIndex(-1); }, - ...filterDataAttributes(this.props), - })} - hasSelection={Boolean(selectedItem)} - onClear={clearSelection} - isOpen={isOpen} - isCondensed={this.props.isCondensed} - isDisabled={this.props.isDisabled} - isReadOnly={this.props.isReadOnly} - toggleButtonProps={getToggleButtonProps({ - onBlur: ( - event: TFocusEventWithPreventDefault - ) => - createBlurHandler( - this.timeInputRef as RefObject - )(event), })} - hasError={this.props.hasError} - hasWarning={this.props.hasWarning} - /> - {((isOpen && !this.props.isDisabled) || - (appearance === 'filter' && !this.props.isDisabled)) && ( - - this.jumpMonths(-1)} - onTodayClick={this.showToday} - onNextMonthClick={() => this.jumpMonths(1)} - onPrevYearClick={() => this.jumpMonths(-12)} - onNextYearClick={() => this.jumpMonths(12)} - /> - - {weekdays.map((weekday) => ( - - {weekday} - - ))} - {paddingDays.map((_, index) => ( - - ))} - {calendarItems.map((item, index) => ( - { - setHighlightedIndex(-1); - }, - })} - isHighlighted={ - suggestedItems.length + index === highlightedIndex - } - isSelected={isSameDay(item, this.props.value)} - > - {getCalendarDayLabel(item, this.props.timeZone)} - - ))} - - { - if (event.key === 'ArrowUp') { - setHighlightedIndex(-1); - this.inputRef.current?.focus(); - return; - } + isHighlighted={ + suggestedItems.length + index === downshiftHighlightedIndex + } + isSelected={isSameDay(item, props.value)} + > + {getCalendarDayLabel(item, props.timeZone)} + + ))} + + { + if (event.key === 'ArrowUp') { + setDownshiftHighlightedIndex(-1); + inputRef.current?.focus(); + return; + } + if (event.key === 'Enter') { + setDownshiftHighlightedIndex(-1); + inputRef.current?.focus(); + inputRef.current?.setSelectionRange(0, 100); + closeMenu(); + } + }} + /> + + )} +
+ + ); +}; - if (event.key === 'Enter') { - setHighlightedIndex(-1); - this.inputRef.current?.focus(); - this.inputRef.current?.setSelectionRange(0, 100); - closeMenu(); - } - }} - /> - - )} -
- ); - }} -
-
- ); - } -} +DateTimeInput.displayName = 'DateTimeInput'; -export default injectIntl(DateTimeInput); +export default DateTimeInput; From 9668e081e968dcced62631c6e8a805f326475617 Mon Sep 17 00:00:00 2001 From: "Jaikumar.T.J" Date: Fri, 26 Jun 2026 16:30:44 +0200 Subject: [PATCH 2/2] chore: refactoring --- .../inputs/date-input/src/date-input.tsx | 5 ----- .../date-range-input/src/date-range-input.tsx | 15 ++++----------- .../date-time-input/src/date-time-input.tsx | 14 +++++++++++--- 3 files changed, 15 insertions(+), 19 deletions(-) diff --git a/packages/components/inputs/date-input/src/date-input.tsx b/packages/components/inputs/date-input/src/date-input.tsx index 3e7cba38c2..ad3f59ad33 100644 --- a/packages/components/inputs/date-input/src/date-input.tsx +++ b/packages/components/inputs/date-input/src/date-input.tsx @@ -233,11 +233,6 @@ const DateInput = (props: TDateInput) => { highlightedIndex: highlightedIndex ?? -1, isItemDisabled: (item) => !getIsDateInRange(item, props.minValue, props.maxValue), - onHighlightedIndexChange: ({ highlightedIndex: newIndex }) => { - if (newIndex !== undefined && newIndex !== -1) { - setHighlightedIndex(newIndex); - } - }, onSelectedItemChange: ({ selectedItem: newItem }) => { handleChange(newItem ?? null); }, diff --git a/packages/components/inputs/date-range-input/src/date-range-input.tsx b/packages/components/inputs/date-range-input/src/date-range-input.tsx index 23d3deca11..f7a98b906c 100644 --- a/packages/components/inputs/date-range-input/src/date-range-input.tsx +++ b/packages/components/inputs/date-range-input/src/date-range-input.tsx @@ -317,9 +317,6 @@ const DateRangeInput = ({ } return changes; }, - onIsOpenChange: ({ isOpen: newIsOpen }) => { - setIsOpen(newIsOpen ?? false); - }, onInputValueChange: ({ inputValue: newInputValue, type }) => { // only attempt to parse input when the user typed into the input field if (type !== useCombobox.stateChangeTypes.InputChange) return; @@ -345,13 +342,6 @@ const DateRangeInput = ({ setCalendarDate(calDate); } }, - onSelectedItemChange: ({ selectedItem: newItem }) => { - if (startDate && newItem) { - emit([startDate, newItem]); - } else { - emit([]); - } - }, onStateChange: (changes) => { if ( changes.type === useCombobox.stateChangeTypes.MenuMouseLeave || @@ -371,6 +361,10 @@ const DateRangeInput = ({ startDate && changes.selectedItem ); + // startDate here is from the pre-click render closure — the correct "first date" + if (hasFinishedRangeSelection) { + emit([startDate, changes.selectedItem as MomentInput]); + } setHighlightedIndex(highlightedIndex); setStartDate(startDate ? null : (changes.selectedItem as MomentInput)); if (changes.selectedItem) { @@ -598,6 +592,5 @@ const DateRangeInput = ({ DateRangeInput.displayName = 'DateRangeInput'; DateRangeInput.isEmpty = (range: number[]) => range.length === 0; -DateRangeInput.defaultProps = { isClearable: true }; export default DateRangeInput; diff --git a/packages/components/inputs/date-time-input/src/date-time-input.tsx b/packages/components/inputs/date-time-input/src/date-time-input.tsx index 2af2711940..241cacdedd 100644 --- a/packages/components/inputs/date-time-input/src/date-time-input.tsx +++ b/packages/components/inputs/date-time-input/src/date-time-input.tsx @@ -292,6 +292,7 @@ const DateTimeInput = (props: TDateTimeInputProps) => { stateReducer: (state, { type, changes }) => { if ( type === useCombobox.stateChangeTypes.InputBlur && + typeof document !== 'undefined' && document.activeElement === timeInputRef.current ) { return { ...changes, isOpen: state.isOpen }; @@ -332,10 +333,10 @@ const DateTimeInput = (props: TDateTimeInputProps) => { } if (changes.hasOwnProperty('isOpen')) { - setTimeString( + setTimeString((prev) => changes.isOpen && props.value !== '' ? formatTime(props.value, intl.locale, props.timeZone) - : timeString + : prev ); setCalendarDate( props.value === '' @@ -436,7 +437,14 @@ const DateTimeInput = (props: TDateTimeInputProps) => { } }, onClick: props.isReadOnly - ? (preventDownshiftDefault as unknown as MouseEventHandler) + ? (((event) => { + // Tell downshift not to open the menu in read-only mode + ( + event.nativeEvent as unknown as { + preventDownshiftDefault?: boolean; + } + ).preventDownshiftDefault = true; + }) satisfies MouseEventHandler) : undefined, onBlur: ( event: TFocusEventWithPreventDefault