From d6069447aeb156974c7af18050e43ba9443fa4d3 Mon Sep 17 00:00:00 2001 From: Simon Guo Date: Thu, 9 May 2024 18:49:30 +0800 Subject: [PATCH] fix(DateInput,DateRangeInput): fix the issue of incorrect cursor position in the input box (#3785) * fix(DateInput,DateRangeInput): fix the issue of incorrect cursor position in the input box * test: add tests for input continuously * test: add tests for input continuously * test: add tests for input continuously --- src/DateInput/DateField.ts | 4 +- src/DateInput/DateInput.tsx | 48 ++++++-------- .../{ => hooks}/useDateInputState.ts | 4 +- src/DateInput/hooks/useFieldCursor.ts | 55 ++++++++++++++++ src/DateInput/{ => hooks}/useIsFocused.ts | 0 .../{ => hooks}/useKeyboardInputEvent.ts | 0 src/DateInput/hooks/useSelectedState.ts | 19 ++++++ src/DateInput/index.tsx | 8 ++- src/DateInput/test/DateInputSpec.tsx | 35 ++++++++++- src/DateInput/test/testUtils.tsx | 63 +++++++++++++++++-- src/DateInput/utils.ts | 30 +-------- src/DateRangeInput/DateRangeInput.tsx | 37 +++++------ .../test/DateRangeInputSpec.tsx | 25 +++++++- 13 files changed, 235 insertions(+), 93 deletions(-) rename src/DateInput/{ => hooks}/useDateInputState.ts (97%) create mode 100644 src/DateInput/hooks/useFieldCursor.ts rename src/DateInput/{ => hooks}/useIsFocused.ts (100%) rename src/DateInput/{ => hooks}/useKeyboardInputEvent.ts (100%) create mode 100644 src/DateInput/hooks/useSelectedState.ts diff --git a/src/DateInput/DateField.ts b/src/DateInput/DateField.ts index 7335bdc690..4a10de9190 100644 --- a/src/DateInput/DateField.ts +++ b/src/DateInput/DateField.ts @@ -120,7 +120,9 @@ export const useDateField = (format: string, localize: Locale['localize'], date? value = padNumber(value, pattern.length); } - str = str.replace(pattern, value); + if (typeof value !== 'undefined') { + str = str.replace(pattern, value); + } } }); diff --git a/src/DateInput/DateInput.tsx b/src/DateInput/DateInput.tsx index a6f8ce5787..f354c3bb57 100644 --- a/src/DateInput/DateInput.tsx +++ b/src/DateInput/DateInput.tsx @@ -1,18 +1,14 @@ -import React, { useState, useRef, useMemo } from 'react'; +import React, { useRef, useMemo } from 'react'; import PropTypes from 'prop-types'; import Input, { InputProps } from '../Input'; import { mergeRefs, useCustom, useControlled, useEventCallback } from '../utils'; import { FormControlBaseProps } from '../@types/common'; -import { - getInputSelectedState, - validateDateTime, - isFieldFullValue, - useInputSelection -} from './utils'; - -import useDateInputState from './useDateInputState'; -import useKeyboardInputEvent from './useKeyboardInputEvent'; -import useIsFocused from './useIsFocused'; +import { getInputSelectedState, validateDateTime, useInputSelection } from './utils'; +import useDateInputState from './hooks/useDateInputState'; +import useKeyboardInputEvent from './hooks/useKeyboardInputEvent'; +import useIsFocused from './hooks/useIsFocused'; +import useFieldCursor from './hooks/useFieldCursor'; +import useSelectedState from './hooks/useSelectedState'; export interface DateInputProps extends Omit, @@ -51,15 +47,7 @@ const DateInput = React.forwardRef((props: DateInputProps, ref) => { const inputRef = useRef(); - const [selectedState, setSelectedState] = useState<{ - selectedPattern: string; - selectionStart: number; - selectionEnd: number; - }>({ - selectedPattern: 'y', - selectionStart: 0, - selectionEnd: 0 - }); + const { selectedState, setSelectedState } = useSelectedState(); const { locale } = useCustom('Calendar'); @@ -73,6 +61,8 @@ const DateInput = React.forwardRef((props: DateInputProps, ref) => { isControlledDate: isControlled }); + const { isMoveCursor, increment, reset } = useFieldCursor(formatStr, valueProp); + const dateString = toDateString(); const keyPressOptions = useMemo( () => ({ @@ -101,8 +91,9 @@ const DateInput = React.forwardRef((props: DateInputProps, ref) => { const state = getInputSelectedState({ ...keyPressOptions, input, direction }); - setSelectionRange(state.selectionStart, state.selectionEnd); setSelectedState(state); + setSelectionRange(state.selectionStart, state.selectionEnd); + reset(); } ); @@ -128,6 +119,8 @@ const DateInput = React.forwardRef((props: DateInputProps, ref) => { return; } + increment(); + const field = getDateField(pattern); const value = parseInt(key, 10); const padValue = parseInt(`${field.value || ''}${key}`, 10); @@ -139,11 +132,6 @@ const DateInput = React.forwardRef((props: DateInputProps, ref) => { newValue = padValue; } - if (pattern === 'M') { - // Month cannot be less than 1. - newValue = Math.max(1, newValue); - } - setDateField(pattern, newValue, date => handleChange(date, event)); // The currently selected month will be retained as a parameter of getInputSelectedState, @@ -155,10 +143,7 @@ const DateInput = React.forwardRef((props: DateInputProps, ref) => { setSelectionRange(nextState.selectionStart, nextState.selectionEnd); // If the field is full value, move the cursor to the next field - if ( - isFieldFullValue(formatStr, newValue, pattern) && - input.selectionEnd !== input.value.length - ) { + if (isMoveCursor(newValue, pattern) && input.selectionEnd !== input.value.length) { onSegmentChange(event, 'right'); } } @@ -166,6 +151,7 @@ const DateInput = React.forwardRef((props: DateInputProps, ref) => { const onSegmentValueRemove = useEventCallback((event: React.KeyboardEvent) => { const input = event.target as HTMLInputElement; + if (selectedState.selectedPattern) { const nextState = getInputSelectedState({ ...keyPressOptions, input, valueOffset: null }); @@ -173,6 +159,8 @@ const DateInput = React.forwardRef((props: DateInputProps, ref) => { setSelectionRange(nextState.selectionStart, nextState.selectionEnd); setDateField(selectedState.selectedPattern, null, date => handleChange(date, event)); + + reset(); } }); diff --git a/src/DateInput/useDateInputState.ts b/src/DateInput/hooks/useDateInputState.ts similarity index 97% rename from src/DateInput/useDateInputState.ts rename to src/DateInput/hooks/useDateInputState.ts index a87f1a1328..aa78aff882 100644 --- a/src/DateInput/useDateInputState.ts +++ b/src/DateInput/hooks/useDateInputState.ts @@ -11,8 +11,8 @@ import { isLastDayOfMonth, lastDayOfMonth, isValid -} from '../utils/dateUtils'; -import { useDateField, patternMap } from './DateField'; +} from '../../utils/dateUtils'; +import { useDateField, patternMap } from '../DateField'; import type { Locale } from 'date-fns'; interface DateInputState { diff --git a/src/DateInput/hooks/useFieldCursor.ts b/src/DateInput/hooks/useFieldCursor.ts new file mode 100644 index 0000000000..ccfdba5d35 --- /dev/null +++ b/src/DateInput/hooks/useFieldCursor.ts @@ -0,0 +1,55 @@ +import { useCallback, useRef } from 'react'; +import { getPatternGroups } from '../utils'; +import useUpdateEffect from '../../utils/useUpdateEffect'; +export function useFieldCursor(format: string, value?: V) { + const typeCount = useRef(0); + + const increment = () => { + typeCount.current += 1; + }; + + const reset = () => { + typeCount.current = 0; + }; + + const isMoveCursor = useCallback( + (value: number, pattern: string) => { + const patternGroup = getPatternGroups(format, pattern); + + if (value.toString().length === patternGroup.length) { + return true; + } else if (pattern === 'y' && typeCount.current === 4) { + return true; + } else if (pattern !== 'y' && typeCount.current === 2) { + return true; + } + + switch (pattern) { + case 'M': + return parseInt(`${value}0`) > 12; + case 'd': + return parseInt(`${value}0`) > 31; + case 'H': + return parseInt(`${value}0`) > 23; + case 'h': + return parseInt(`${value}0`) > 12; + case 'm': + case 's': + return parseInt(`${value}0`) > 59; + default: + return false; + } + }, + [format] + ); + + useUpdateEffect(() => { + if (!value) { + reset(); + } + }, [value]); + + return { increment, reset, isMoveCursor }; +} + +export default useFieldCursor; diff --git a/src/DateInput/useIsFocused.ts b/src/DateInput/hooks/useIsFocused.ts similarity index 100% rename from src/DateInput/useIsFocused.ts rename to src/DateInput/hooks/useIsFocused.ts diff --git a/src/DateInput/useKeyboardInputEvent.ts b/src/DateInput/hooks/useKeyboardInputEvent.ts similarity index 100% rename from src/DateInput/useKeyboardInputEvent.ts rename to src/DateInput/hooks/useKeyboardInputEvent.ts diff --git a/src/DateInput/hooks/useSelectedState.ts b/src/DateInput/hooks/useSelectedState.ts new file mode 100644 index 0000000000..e3e6b3d106 --- /dev/null +++ b/src/DateInput/hooks/useSelectedState.ts @@ -0,0 +1,19 @@ +import { useState } from 'react'; + +const defaultSelectedState = { + selectedPattern: 'y', + selectionStart: 0, + selectionEnd: 0 +}; + +export function useSelectedState() { + const [selectedState, setSelectedState] = useState<{ + selectedPattern: string; + selectionStart: number; + selectionEnd: number; + }>(defaultSelectedState); + + return { selectedState, setSelectedState }; +} + +export default useSelectedState; diff --git a/src/DateInput/index.tsx b/src/DateInput/index.tsx index ea09ed35d7..ad38bd8584 100644 --- a/src/DateInput/index.tsx +++ b/src/DateInput/index.tsx @@ -1,7 +1,9 @@ import DateInput from './DateInput'; -export { useDateInputState } from './useDateInputState'; -export { useKeyboardInputEvent } from './useKeyboardInputEvent'; -export { useIsFocused } from './useIsFocused'; +export { useDateInputState } from './hooks/useDateInputState'; +export { useKeyboardInputEvent } from './hooks/useKeyboardInputEvent'; +export { useIsFocused } from './hooks/useIsFocused'; +export { useSelectedState } from './hooks/useSelectedState'; +export { useFieldCursor } from './hooks/useFieldCursor'; export * from './utils'; export type { DateInputProps } from './DateInput'; export default DateInput; diff --git a/src/DateInput/test/DateInputSpec.tsx b/src/DateInput/test/DateInputSpec.tsx index 9970abc60d..882079326a 100644 --- a/src/DateInput/test/DateInputSpec.tsx +++ b/src/DateInput/test/DateInputSpec.tsx @@ -9,7 +9,7 @@ import CustomProvider from '../../CustomProvider'; import zhCN from '../../locales/zh_CN'; import { keyPressTests } from './testUtils'; -const { testKeyPress, testContinuousKeyPress } = keyPressTests(DateInput); +const { testKeyPress, testKeyPressAsync, testContinuousKeyPress } = keyPressTests(DateInput); describe('DateInput', () => { testStandardProps(, { sizes: ['lg', 'md', 'sm', 'xs'] }); @@ -72,7 +72,7 @@ describe('DateInput', () => { it('Should call `onChange` with the new value', () => { const onChange = sinon.spy(); - render(); + render(); const input = screen.getByRole('textbox') as HTMLInputElement; @@ -456,5 +456,36 @@ describe('DateInput', () => { ] }); }); + + it('Should be able to enter key input continuously', async () => { + await testKeyPressAsync({ + keys: '20240101'.split(''), + expectedValue: '2024-01-01' + }); + }); + + it('Should be able to enter key input continuously with 24 hour format', async () => { + await testKeyPressAsync({ + format: 'MM/dd/yyyy HH:mm:ss', + keys: '01012024 120130'.split(''), + expectedValue: '01/01/2024 12:01:30' + }); + }); + + it('Should be able to enter key input continuously with 12 hour format', async () => { + await testKeyPressAsync({ + format: 'MM/dd/yyyy hh:mm:ss', + keys: '01012024 140130'.split(''), + expectedValue: '01/01/2024 02:01:30' + }); + }); + + it('Should be able to enter key input continuously with abbreviated month', async () => { + await testKeyPressAsync({ + format: 'MMM dd,yyyy', + keys: '01012024'.split(''), + expectedValue: 'Jan 01,2024' + }); + }); }); }); diff --git a/src/DateInput/test/testUtils.tsx b/src/DateInput/test/testUtils.tsx index d089a5799d..05cf934b25 100644 --- a/src/DateInput/test/testUtils.tsx +++ b/src/DateInput/test/testUtils.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { render, screen } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import sinon from 'sinon'; import userEvent from '@testing-library/user-event'; import isMatch from 'date-fns/isMatch'; @@ -18,13 +18,20 @@ export function keyPressTests(TestComponent: React.FC) { key: string; }) { const onChange = sinon.spy(); - render(); + render( + + ); const input = screen.getByRole('textbox') as HTMLInputElement; userEvent.click(input); - userEvent.type(input, key); + userEvent.type(input, key); expect(input).to.value(expectedValue); if (isMatch(expectedValue, format)) { @@ -32,6 +39,45 @@ export function keyPressTests(TestComponent: React.FC) { } } + async function testKeyPressAsync({ + defaultValue, + format = 'yyyy-MM-dd', + expectedValue, + keys + }: { + defaultValue?: Date | [Date | null, Date | null] | null; + format?: string; + expectedValue: string; + keys: string[]; + }) { + const onChange = sinon.spy(); + render(); + + const input = screen.getByRole('textbox') as HTMLInputElement; + + userEvent.click(input); + + let timeout = 0; + + const actions = keys.map(key => { + timeout += 100; + return new Promise(resolve => { + setTimeout(() => { + fireEvent.keyDown(input, { key }); + resolve(); + }, timeout); + }); + }); + + await Promise.all(actions); + + expect(input).to.value(expectedValue); + + if (isMatch(expectedValue, format)) { + expect(formatDate(onChange.lastCall.firstArg, format)).to.equal(expectedValue); + } + } + function testContinuousKeyPress({ keySequences, defaultValue, @@ -45,7 +91,14 @@ export function keyPressTests(TestComponent: React.FC) { }[]; }) { const onChange = sinon.spy(); - render(); + render( + + ); const input = screen.getByRole('textbox') as HTMLInputElement; @@ -57,5 +110,5 @@ export function keyPressTests(TestComponent: React.FC) { } } - return { testKeyPress, testContinuousKeyPress }; + return { testKeyPress, testKeyPressAsync, testContinuousKeyPress }; } diff --git a/src/DateInput/utils.ts b/src/DateInput/utils.ts index 0114b6c5a4..78121f8523 100644 --- a/src/DateInput/utils.ts +++ b/src/DateInput/utils.ts @@ -276,35 +276,11 @@ export function modifyDate(date: Date, type: string, value: number) { return date; } -export function isFieldFullValue(formatStr: string, value: number, pattern: string) { - const patternGroup = getPatternGroups(formatStr, pattern); - - if (value.toString().length === patternGroup.length) { - return true; - } - - switch (pattern) { - case 'M': - return parseInt(`${value}0`) > 12; - case 'd': - return parseInt(`${value}0`) > 31; - case 'H': - return parseInt(`${value}0`) > 23; - case 'h': - return parseInt(`${value}0`) > 12; - case 'm': - case 's': - return parseInt(`${value}0`) > 59; - default: - return false; - } -} - -const isTestEnvironment = typeof process !== 'undefined' && process.env.RUN_ENV === 'test'; - export function useInputSelection(input: React.RefObject) { return function setSelectionRange(selectionStart: number, selectionEnd: number) { - if (isTestEnvironment) { + const isTest = input.current.dataset.test === 'true'; + + if (isTest) { safeSetSelection(input.current, selectionStart, selectionEnd); return; } diff --git a/src/DateRangeInput/DateRangeInput.tsx b/src/DateRangeInput/DateRangeInput.tsx index f1dc80e482..2e41d5f91d 100644 --- a/src/DateRangeInput/DateRangeInput.tsx +++ b/src/DateRangeInput/DateRangeInput.tsx @@ -4,11 +4,12 @@ import Input, { InputProps } from '../Input'; import { mergeRefs, useClassNames, useCustom, useControlled, useEventCallback } from '../utils'; import { validateDateTime, - isFieldFullValue, useDateInputState, useInputSelection, useKeyboardInputEvent, - useIsFocused + useIsFocused, + useSelectedState, + useFieldCursor } from '../DateInput'; import { getInputSelectedState, DateType, getDateType, isSwitchDateType } from './utils'; import { FormControlBaseProps } from '../@types/common'; @@ -64,15 +65,7 @@ const DateRangeInput = React.forwardRef((props: DateRangeInputProps, ref) => { const inputRef = useRef(); - const [selectedState, setSelectedState] = useState<{ - selectedPattern: string; - selectionStart: number; - selectionEnd: number; - }>({ - selectedPattern: 'y', - selectionStart: 0, - selectionEnd: 0 - }); + const { selectedState, setSelectedState } = useSelectedState(); const { locale } = useCustom('Calendar'); const rangeFormatStr = `${formatStr}${character}${formatStr}`; @@ -86,6 +79,8 @@ const DateRangeInput = React.forwardRef((props: DateRangeInputProps, ref) => { const startDateState = useDateInputState({ ...dateInputOptions, date: value?.[0] || null }); const endDateState = useDateInputState({ ...dateInputOptions, date: value?.[1] || null }); + const { isMoveCursor, increment, reset } = useFieldCursor(formatStr, valueProp); + const getActiveState = (type: DateType = dateType) => { return type === DateType.Start ? startDateState : endDateState; }; @@ -152,8 +147,9 @@ const DateRangeInput = React.forwardRef((props: DateRangeInputProps, ref) => { direction }); - setSelectionRange(state.selectionStart, state.selectionEnd); setSelectedState(state); + setSelectionRange(state.selectionStart, state.selectionEnd); + reset(); } ); @@ -176,10 +172,13 @@ const DateRangeInput = React.forwardRef((props: DateRangeInputProps, ref) => { const input = event.target as HTMLInputElement; const key = event.key; const pattern = selectedState.selectedPattern; + if (!pattern) { return; } + increment(); + const field = getActiveState().getDateField(pattern); const value = parseInt(key, 10); const padValue = parseInt(`${field.value || ''}${key}`, 10); @@ -191,11 +190,6 @@ const DateRangeInput = React.forwardRef((props: DateRangeInputProps, ref) => { newValue = padValue; } - if (pattern === 'M') { - // Month cannot be less than 1. - newValue = Math.max(1, newValue); - } - getActiveState().setDateField(pattern, newValue, date => handleChange(date, event)); // The currently selected month will be retained as a parameter of getInputSelectedState, @@ -207,10 +201,7 @@ const DateRangeInput = React.forwardRef((props: DateRangeInputProps, ref) => { setSelectionRange(nextState.selectionStart, nextState.selectionEnd); // If the field is full value, move the cursor to the next field - if ( - isFieldFullValue(formatStr, newValue, pattern) && - input.selectionEnd !== input.value.length - ) { + if (isMoveCursor(newValue, pattern) && input.selectionEnd !== input.value.length) { onSegmentChange(event, 'right'); } } @@ -227,6 +218,8 @@ const DateRangeInput = React.forwardRef((props: DateRangeInputProps, ref) => { getActiveState().setDateField(selectedState.selectedPattern, null, date => handleChange(date, event) ); + + reset(); } }); @@ -239,7 +232,7 @@ const DateRangeInput = React.forwardRef((props: DateRangeInputProps, ref) => { const cursorIndex = input.selectionStart === renderedValue.length ? 0 : input.selectionStart; - const dateType = getDateType(renderedValue, character, cursorIndex); + const dateType = getDateType(renderedValue || rangeFormatStr, character, cursorIndex); const state = getInputSelectedState({ ...keyPressOptions, dateType, diff --git a/src/DateRangeInput/test/DateRangeInputSpec.tsx b/src/DateRangeInput/test/DateRangeInputSpec.tsx index 97529f2f4d..39aa2d90d8 100644 --- a/src/DateRangeInput/test/DateRangeInputSpec.tsx +++ b/src/DateRangeInput/test/DateRangeInputSpec.tsx @@ -9,7 +9,7 @@ import CustomProvider from '../../CustomProvider'; import zhCN from '../../locales/zh_CN'; import { keyPressTests } from '../../DateInput/test/testUtils'; -const { testKeyPress, testContinuousKeyPress } = keyPressTests(DateRangeInput); +const { testKeyPress, testKeyPressAsync, testContinuousKeyPress } = keyPressTests(DateRangeInput); describe('DateRangeInput', () => { testStandardProps(, { sizes: ['lg', 'md', 'sm', 'xs'] }); @@ -460,5 +460,28 @@ describe('DateRangeInput', () => { ] }); }); + + it('Should be able to enter key input continuously', async () => { + await testKeyPressAsync({ + keys: '2024010120240202'.split(''), + expectedValue: '2024-01-01 ~ 2024-02-02' + }); + }); + + it('Should be able to enter key input continuously with custom format', async () => { + await testKeyPressAsync({ + format: 'MM/dd/yyyy', + keys: '0101202402022024'.split(''), + expectedValue: '01/01/2024 ~ 02/02/2024' + }); + }); + + it('Should be able to enter key input continuously with abbreviated month', async () => { + await testKeyPressAsync({ + format: 'MMM dd,yyyy', + keys: '0101202402022024'.split(''), + expectedValue: 'Jan 01,2024 ~ Feb 02,2024' + }); + }); }); });