diff --git a/src/components/DateField/DateField.tsx b/src/components/DateField/DateField.tsx index 13637b1d..c9488fbc 100644 --- a/src/components/DateField/DateField.tsx +++ b/src/components/DateField/DateField.tsx @@ -19,10 +19,13 @@ const b = block('date-field'); export function DateField({className, ...props}: DateFieldProps) { const state = useDateFieldState(props); - const {inputProps} = useDateFieldProps(state, props); + const { + inputProps: {onBlur, ...inputProps}, + } = useDateFieldProps(state, props); const [isActive, setActive] = React.useState(false); const {focusWithinProps} = useFocusWithin({ + onBlurWithin: onBlur, onFocusWithinChange(isFocusWithin) { setActive(isFocusWithin); }, diff --git a/src/components/DateField/__tests__/useDateFieldState.test.ts b/src/components/DateField/__tests__/useDateFieldState.test.ts deleted file mode 100644 index fc44dc5a..00000000 --- a/src/components/DateField/__tests__/useDateFieldState.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import {dateTime} from '@gravity-ui/date-utils'; -import {describe, expect, it, vi} from 'vitest'; - -import {renderHook} from '#test-utils/utils'; - -import {useDateFieldState} from '../hooks/useDateFieldState'; -import {cleanString} from '../utils'; - -describe('invalid date entry', () => { - it('allows entering day 31 even if placeholder month has 30 days', async () => { - const {result, act} = await renderHook(() => - useDateFieldState({ - format: 'DD.MM.YYYY', - placeholderValue: dateTime({input: '2024-06-15', format: 'YYYY-MM-DD'}), - }), - ); - - const dayIndex = result.current.sections.findIndex((section) => section.type === 'day'); - - act(() => { - result.current.setSelectedSections(dayIndex); - result.current.onInput('3'); - result.current.onInput('1'); - }); - - const daySection = result.current.sections[dayIndex]; - expect(daySection.value).toBe(31); - expect(cleanString(daySection.textValue)).toBe('31'); - }); - - it('suppresses updates, and constrains on blur', async () => { - const onUpdate = vi.fn(); - const {result, act} = await renderHook(() => - useDateFieldState({ - format: 'DD.MM.YYYY', - onUpdate, - }), - ); - - const dayIndex = result.current.sections.findIndex((section) => section.type === 'day'); - const monthIndex = result.current.sections.findIndex((section) => section.type === 'month'); - const yearIndex = result.current.sections.findIndex((section) => section.type === 'year'); - - act(() => { - result.current.setSelectedSections(dayIndex); - }); - act(() => { - result.current.onInput('3'); - }); - act(() => { - result.current.onInput('1'); - }); - act(() => { - result.current.setSelectedSections(monthIndex); - }); - act(() => { - result.current.onInput('0'); - }); - act(() => { - result.current.onInput('4'); - }); - act(() => { - result.current.setSelectedSections(yearIndex); - }); - act(() => { - result.current.onInput('2'); - }); - act(() => { - result.current.onInput('0'); - }); - act(() => { - result.current.onInput('2'); - }); - act(() => { - result.current.onInput('4'); - }); - - expect(result.current.value).toBeNull(); - expect(onUpdate).not.toHaveBeenCalled(); - - act(() => { - result.current.confirmPlaceholder(); - }); - - expect(result.current.validationState).toBeUndefined(); - expect(result.current.value?.format('DD.MM.YYYY')).toBe('30.04.2024'); - expect(onUpdate).toHaveBeenCalled(); - }); -}); diff --git a/src/components/DateField/hooks/useBaseDateFieldState.ts b/src/components/DateField/hooks/useBaseDateFieldState.ts index 7bb83aba..d5b135e7 100644 --- a/src/components/DateField/hooks/useBaseDateFieldState.ts +++ b/src/components/DateField/hooks/useBaseDateFieldState.ts @@ -1,41 +1,28 @@ -import React from 'react'; - import type {DateTime} from '@gravity-ui/date-utils'; import type {ValidationState} from '../../types'; import type {IncompleteDate} from '../IncompleteDate'; import type {DateFieldSection, FormatInfo} from '../types'; -import { - PAGE_STEP, - formatSections, - getCurrentEditableSectionIndex, - isEditableSectionType, -} from '../utils'; +import {PAGE_STEP} from '../utils'; -export type BaseDateFieldStateOptions = { +interface BaseDateFieldStateOptions { value: T | null; displayValue: T; - placeholderValue?: DateTime; - timeZone: string; validationState?: ValidationState; editableSections: DateFieldSection[]; formatInfo: FormatInfo; readOnly?: boolean; disabled?: boolean; - selectedSectionIndexes: {startIndex: number; endIndex: number} | null; - selectedSections: number | 'all'; isEmpty: boolean; - setSelectedSections: (position: number | 'all') => void; setValue: (value: T | V | null) => void; adjustSection: (sectionIndex: number, amount: number) => void; setSection: (sectionIndex: number, amount: number) => void; - getSectionValue: (sectionIndex: number) => IncompleteDate; - setSectionValue: (sectionIndex: number, currentValue: IncompleteDate) => void; + clearSection: (sectionIndex: number) => void; setValueFromString: (str: string) => boolean; confirmPlaceholder: () => void; -}; +} -export type DateFieldState = { +export interface DateFieldState { /** The current field value. */ value: T | null; /** Is no part of value is filled. */ @@ -44,61 +31,45 @@ export type DateFieldState = { displayValue: T; /** Sets the field's value. */ setValue: (value: T | null) => void; - /** Updates the remaining unfilled segments with the placeholder value. */ + /** Updates the remaining unfilled sections with the placeholder value. */ confirmPlaceholder: () => void; - /** Formatted value */ - text: string; /** Whether the field is read only. */ readOnly?: boolean; /** Whether the field is disabled. */ disabled?: boolean; - /** A list of segments for the current value. */ + /** A list of sections for the current value. */ sections: DateFieldSection[]; /** Some info about available sections */ formatInfo: FormatInfo; - /** Selected sections */ - selectedSectionIndexes: {startIndex: number; endIndex: number} | null; /** The current validation state of the date field, based on the `validationState`, `minValue`, and `maxValue` props. */ validationState?: ValidationState; - /** Sets selection for segment in position. */ - setSelectedSections: (position: number | 'all') => void; - /** Focuses segment in position */ - focusSectionInPosition: (position: number) => void; - /** Focuses the next segment if present */ - focusNextSection: () => void; - /** Focuses the previous segment if present */ - focusPreviousSection: () => void; - /** Focuses the first segment */ - focusFirstSection: () => void; - /** Focuses the last segment */ - focusLastSection: () => void; - /** Increments the currently selected segment. Upon reaching the minimum or maximum value, the value wraps around to the opposite limit. */ - increment: () => void; - /** Decrements the currently selected segment. Upon reaching the minimum or maximum value, the value wraps around to the opposite limit. */ - decrement: () => void; + /** Increments the currently selected section. Upon reaching the minimum or maximum value, the value wraps around to the opposite limit. */ + increment: (sectionIndex: number) => void; + /** Decrements the currently selected section. Upon reaching the minimum or maximum value, the value wraps around to the opposite limit. */ + decrement: (sectionIndex: number) => void; /** - * Increments the currently selected segment by a larger amount, rounding it to the nearest increment. + * Increments the currently selected section by a larger amount, rounding it to the nearest increment. * The amount to increment by depends on the field, for example 15 minutes, 7 days, and 5 years. * Upon reaching the minimum or maximum value, the value wraps around to the opposite limit. */ - incrementPage: () => void; + incrementPage: (sectionIndex: number) => void; /** - * Decrements the currently selected segment by a larger amount, rounding it to the nearest decrement. + * Decrements the currently selected section by a larger amount, rounding it to the nearest decrement. * The amount to increment by depends on the field, for example 15 minutes, 7 days, and 5 years. * Upon reaching the minimum or maximum value, the value wraps around to the opposite limit. */ - decrementPage: () => void; - incrementToMax: () => void; - decrementToMin: () => void; - /** Clears the value of the currently selected segment, reverting it to the placeholder. */ - clearSection: () => void; - /** Clears all segments, reverting them to the placeholder. */ + decrementPage: (sectionIndex: number) => void; + incrementToMax: (sectionIndex: number) => void; + decrementToMin: (sectionIndex: number) => void; + /** Clears the value of the currently selected section, reverting it to the placeholder. */ + clearSection: (sectionIndex: number) => void; + /** Clears all sections, reverting them to the placeholder. */ clearAll: () => void; - /** Handles input key in the currently selected segment */ - onInput: (key: string) => void; + /** Sets the value of the given section. */ + setSection: (sectionIndex: number, amount: number) => void; //** Tries to set value from str. Supports date in input format or ISO */ setValueFromString: (str: string) => boolean; -}; +} export function useBaseDateFieldState( props: BaseDateFieldStateOptions, @@ -109,126 +80,67 @@ export function useBaseDateFieldState( displayValue, editableSections, formatInfo, - selectedSectionIndexes, - selectedSections, isEmpty, - setSelectedSections, setValue, adjustSection, setSection, - getSectionValue, - setSectionValue, + clearSection, setValueFromString, confirmPlaceholder, } = props; - const enteredKeys = React.useRef(''); - return { value, isEmpty, displayValue, setValue, confirmPlaceholder, - text: formatSections(editableSections), readOnly: props.readOnly, disabled: props.disabled, sections: editableSections, formatInfo, - selectedSectionIndexes, validationState, - setSelectedSections(position) { - enteredKeys.current = ''; - setSelectedSections(position); - }, - focusSectionInPosition(position) { - const nextSectionIndex = this.sections.findIndex((s) => s.end >= position); - const index = nextSectionIndex === -1 ? 0 : nextSectionIndex; - const nextSection = this.sections[index]; - if (nextSection) { - this.setSelectedSections( - isEditableSectionType(nextSection.type) - ? index - : nextSection.nextEditableSection, - ); - } - }, - focusNextSection() { - const currentIndex = selectedSections === 'all' ? 0 : selectedSections; - const newIndex = this.sections[currentIndex]?.nextEditableSection ?? -1; - if (newIndex !== -1) { - this.setSelectedSections(newIndex); - } - }, - focusPreviousSection() { - const currentIndex = selectedSections === 'all' ? 0 : selectedSections; - const newIndex = this.sections[currentIndex]?.previousEditableSection ?? -1; - if (newIndex !== -1) { - this.setSelectedSections(newIndex); - } - }, - focusFirstSection() { - const newIndex = this.sections[0]?.previousEditableSection ?? -1; - if (newIndex !== -1) { - setSelectedSections(newIndex); - } - }, - focusLastSection() { - const newIndex = this.sections[this.sections.length - 1]?.nextEditableSection ?? -1; - if (newIndex !== -1) { - this.setSelectedSections(newIndex); - } - }, - increment() { + setSection, + increment(sectionIndex) { if (this.readOnly || this.disabled) { return; } - enteredKeys.current = ''; - const sectionIndex = getCurrentEditableSectionIndex(this.sections, selectedSections); if (sectionIndex !== -1) { adjustSection(sectionIndex, 1); } }, - decrement() { + decrement(sectionIndex) { if (this.readOnly || this.disabled) { return; } - enteredKeys.current = ''; - const sectionIndex = getCurrentEditableSectionIndex(this.sections, selectedSections); if (sectionIndex !== -1) { adjustSection(sectionIndex, -1); } }, - incrementPage() { + incrementPage(sectionIndex) { if (this.readOnly || this.disabled) { return; } - enteredKeys.current = ''; - const sectionIndex = getCurrentEditableSectionIndex(this.sections, selectedSections); if (sectionIndex !== -1) { adjustSection(sectionIndex, PAGE_STEP[this.sections[sectionIndex].type] || 1); } }, - decrementPage() { + decrementPage(sectionIndex) { if (this.readOnly || this.disabled) { return; } - enteredKeys.current = ''; - const sectionIndex = getCurrentEditableSectionIndex(this.sections, selectedSections); if (sectionIndex !== -1) { adjustSection(sectionIndex, -(PAGE_STEP[this.sections[sectionIndex].type] || 1)); } }, - incrementToMax() { + incrementToMax(sectionIndex) { if (this.readOnly || this.disabled) { return; } - enteredKeys.current = ''; - const sectionIndex = getCurrentEditableSectionIndex(this.sections, selectedSections); if (sectionIndex !== -1) { const section = this.sections[sectionIndex]; if (typeof section.maxValue === 'number') { @@ -236,12 +148,10 @@ export function useBaseDateFieldState( } } }, - decrementToMin() { + decrementToMin(sectionIndex) { if (this.readOnly || this.disabled) { return; } - enteredKeys.current = ''; - const sectionIndex = getCurrentEditableSectionIndex(this.sections, selectedSections); if (sectionIndex !== -1) { const section = this.sections[sectionIndex]; if (typeof section.minValue === 'number') { @@ -249,169 +159,24 @@ export function useBaseDateFieldState( } } }, - clearSection() { + clearSection(sectionIndex) { if (this.readOnly || this.disabled) { return; } - enteredKeys.current = ''; - if (selectedSections === 'all') { - this.clearAll(); - return; - } - - const sectionIndex = getCurrentEditableSectionIndex(this.sections, selectedSections); if (sectionIndex === -1) { return; } - const section = this.sections[sectionIndex]; - - const displayPortion = getSectionValue(sectionIndex); - if (isEditableSectionType(section.type)) { - setSectionValue(sectionIndex, displayPortion.clear(section.type)); - } + clearSection(sectionIndex); }, clearAll() { if (this.readOnly || this.disabled) { return; } - enteredKeys.current = ''; setValue(null); }, - onInput(key: string) { - if (this.readOnly || this.disabled) { - return; - } - - const sectionIndex = getCurrentEditableSectionIndex(this.sections, selectedSections); - if (sectionIndex === -1) { - return; - } - - const section = this.sections[sectionIndex]; - const isLastSection = section.nextEditableSection === sectionIndex; - let newValue = enteredKeys.current + key; - - const onInputNumber = (numberValue: number) => { - let sectionValue = numberValue; - const sectionMaxValue = section.maxValue ?? 0; - const allowsZero = section.minValue === 0; - let shouldResetUserInput; - if ( - // 12-hour clock format with AM/PM - section.type === 'hour' && - (sectionMaxValue === 11 || section.minValue === 12) - ) { - if (sectionValue > 12) { - sectionValue = Number(key); - newValue = key; - } - if (sectionValue === 0) { - sectionValue = -Infinity; - } - if (sectionValue === 12) { - sectionValue = 0; - } - if (section.minValue === 12) { - sectionValue += 12; - } - shouldResetUserInput = Number(newValue + '0') > 12; - } else if (sectionValue > sectionMaxValue) { - sectionValue = Number(key); - newValue = key; - if (sectionValue > sectionMaxValue) { - enteredKeys.current = ''; - return; - } - } - - const shouldSetValue = sectionValue > 0 || (sectionValue === 0 && allowsZero); - if (shouldSetValue) { - setSection(sectionIndex, sectionValue); - } - - if (shouldResetUserInput === undefined) { - shouldResetUserInput = Number(newValue + '0') > sectionMaxValue; - } - const isMaxLength = newValue.length >= String(sectionMaxValue).length; - if (shouldResetUserInput) { - enteredKeys.current = ''; - if (shouldSetValue) { - this.focusNextSection(); - } - } else if (isMaxLength && shouldSetValue && !isLastSection) { - this.focusNextSection(); - } else { - enteredKeys.current = newValue; - } - }; - - const onInputString = (stringValue: string) => { - const options = section.options ?? []; - let sectionValue = stringValue.toLocaleUpperCase(); - let foundOptions = options.filter((v) => v.startsWith(sectionValue)); - if (foundOptions.length === 0) { - if (stringValue !== key) { - sectionValue = key.toLocaleUpperCase(); - foundOptions = options.filter((v) => v.startsWith(sectionValue)); - } - if (foundOptions.length === 0) { - enteredKeys.current = ''; - return; - } - } - const foundValue = foundOptions[0]; - const index = options.indexOf(foundValue); - - if (section.type === 'month') { - setSection(sectionIndex, index + 1); - } else { - setSection(sectionIndex, index); - } - - if (foundOptions.length > 1) { - enteredKeys.current = newValue; - } else { - enteredKeys.current = ''; - this.focusNextSection(); - } - }; - - switch (section.type) { - case 'day': - case 'hour': - case 'minute': - case 'second': - case 'quarter': - case 'year': { - if (!Number.isInteger(Number(newValue))) { - return; - } - const numberValue = Number(newValue); - onInputNumber(numberValue); - break; - } - case 'dayPeriod': { - onInputString(newValue); - break; - } - case 'weekday': - case 'month': { - if (Number.isInteger(Number(newValue))) { - const numberValue = Number(newValue); - onInputNumber(numberValue); - } else { - onInputString(newValue); - } - break; - } - } - }, - setValueFromString(str: string) { - enteredKeys.current = ''; - return setValueFromString(str); - }, + setValueFromString, }; } diff --git a/src/components/DateField/hooks/useDateFieldProps.ts b/src/components/DateField/hooks/useDateFieldProps.ts index f22c256d..899a059a 100644 --- a/src/components/DateField/hooks/useDateFieldProps.ts +++ b/src/components/DateField/hooks/useDateFieldProps.ts @@ -14,9 +14,12 @@ import type { StyleProps, TextInputExtendProps, } from '../../types'; +import {CtrlCmd} from '../../utils/constants.js'; +import type {DateFieldSection} from '../types'; import {cleanString} from '../utils'; import type {DateFieldState} from './useBaseDateFieldState'; +import {useFocusManager} from './useFocusManager'; export interface DateFieldProps extends @@ -38,10 +41,11 @@ export function useDateFieldProps( const [, setInnerState] = React.useState({}); - function setSelectedSections(section: 'all' | number) { - state.setSelectedSections(section); - setInnerState({}); - } + const enteredKeys = React.useRef(''); + + const focusManager = useFocusManager({sections: state.sections}); + + const {text, positions} = React.useMemo(() => prepareState(state.sections), [state.sections]); React.useLayoutEffect(() => { const inputElement = inputRef.current; @@ -49,7 +53,7 @@ export function useDateFieldProps( return; } - if (state.selectedSectionIndexes === null) { + if (focusManager.selectedSectionIndexes === null) { if (inputElement.scrollLeft) { // Ensure that input content is not marked as selected. // setting selection range to 0 causes issues in Safari. @@ -59,8 +63,8 @@ export function useDateFieldProps( return; } - const firstSelectedSection = state.sections[state.selectedSectionIndexes.startIndex]; - const lastSelectedSection = state.sections[state.selectedSectionIndexes.endIndex]; + const firstSelectedSection = positions[focusManager.selectedSectionIndexes.startIndex]; + const lastSelectedSection = positions[focusManager.selectedSectionIndexes.endIndex]; if (firstSelectedSection && lastSelectedSection) { const selectionStart = firstSelectedSection.start; const selectionEnd = lastSelectedSection.end; @@ -75,26 +79,173 @@ export function useDateFieldProps( }); function syncSelectionFromDOM() { - state.focusSectionInPosition(inputRef.current?.selectionStart ?? 0); + enteredKeys.current = ''; + const position = inputRef.current?.selectionStart ?? 0; + const index = positions.findIndex((s) => s.end >= position); + focusManager.focusSection(index === -1 ? 0 : index); setInnerState({}); } const inputMode = React.useMemo(() => { - if (!state.selectedSectionIndexes) { + if (!focusManager.selectedSectionIndexes) { return 'text'; } - const activeSection = state.sections[state.selectedSectionIndexes.startIndex]; + const activeSection = state.sections[focusManager.selectedSectionIndexes.startIndex]; if (!activeSection || activeSection.contentType === 'letter') { return 'text'; } return 'tel'; - }, [state.selectedSectionIndexes, state.sections]); + }, [focusManager.selectedSectionIndexes, state.sections]); + + function onInput(key: string) { + if (state.readOnly || state.disabled) { + return; + } + + const sectionIndex = focusManager.activeSectionIndex; + if (sectionIndex === -1) { + return; + } + + const section = state.sections[sectionIndex]; + const isLastSection = focusManager.isLastSection(sectionIndex); + let newValue = enteredKeys.current + key; + + const onInputNumber = (numberValue: number) => { + let sectionValue = numberValue; + const sectionMaxValue = section.maxValue ?? 0; + const allowsZero = section.minValue === 0; + let shouldResetUserInput; + if ( + // 12-hour clock format with AM/PM + section.type === 'hour' && + (sectionMaxValue === 11 || section.minValue === 12) + ) { + if (sectionValue > 12) { + sectionValue = Number(key); + newValue = key; + } + if (sectionValue === 0) { + sectionValue = -Infinity; + } + if (sectionValue === 12) { + sectionValue = 0; + } + if (section.minValue === 12) { + sectionValue += 12; + } + shouldResetUserInput = Number(newValue + '0') > 12; + } else if (sectionValue > sectionMaxValue) { + sectionValue = Number(key); + newValue = key; + if (sectionValue > sectionMaxValue) { + enteredKeys.current = ''; + return; + } + } + + const shouldSetValue = sectionValue > 0 || (sectionValue === 0 && allowsZero); + if (shouldSetValue) { + state.setSection(sectionIndex, sectionValue); + } + + if (shouldResetUserInput === undefined) { + shouldResetUserInput = Number(newValue + '0') > sectionMaxValue; + } + const isMaxLength = newValue.length >= String(sectionMaxValue).length; + if (shouldResetUserInput) { + enteredKeys.current = ''; + if (shouldSetValue) { + focusManager.focusNextSection(); + } + } else if (isMaxLength && shouldSetValue && !isLastSection) { + focusManager.focusNextSection(); + } else { + enteredKeys.current = newValue; + } + }; + + const onInputString = (stringValue: string) => { + const options = section.options ?? []; + let sectionValue = stringValue.toLocaleUpperCase(); + let foundOptions = options.filter((v) => v.startsWith(sectionValue)); + if (foundOptions.length === 0) { + if (stringValue !== key) { + sectionValue = key.toLocaleUpperCase(); + foundOptions = options.filter((v) => v.startsWith(sectionValue)); + } + if (foundOptions.length === 0) { + enteredKeys.current = ''; + return; + } + } + const foundValue = foundOptions[0]; + const index = options.indexOf(foundValue); + + if (section.type === 'month') { + state.setSection(sectionIndex, index + 1); + } else { + state.setSection(sectionIndex, index); + } + + if (foundOptions.length > 1) { + enteredKeys.current = newValue; + } else { + enteredKeys.current = ''; + focusManager.focusNextSection(); + } + }; + + switch (section.type) { + case 'day': + case 'hour': + case 'minute': + case 'second': + case 'quarter': + case 'year': { + if (!Number.isInteger(Number(newValue))) { + return; + } + const numberValue = Number(newValue); + onInputNumber(numberValue); + break; + } + case 'dayPeriod': { + onInputString(newValue); + break; + } + case 'weekday': + case 'month': { + if (Number.isInteger(Number(newValue))) { + const numberValue = Number(newValue); + onInputNumber(numberValue); + } else { + onInputString(newValue); + } + break; + } + } + } + + function backspace() { + if (focusManager.selectedSectionIndexes === null) { + return; + } + if ( + focusManager.selectedSectionIndexes.startIndex === + focusManager.selectedSectionIndexes.endIndex + ) { + state.clearSection(focusManager.activeSectionIndex); + } else { + state.clearAll(); + } + } return { inputProps: { - value: state.text, + value: text, view: props.view, size: props.size, disabled: state.disabled, @@ -120,32 +271,22 @@ export function useDateFieldProps( onFocus(e) { props.onFocus?.(e); - if (state.selectedSectionIndexes !== null) { + if (focusManager.selectedSectionIndexes !== null) { + setInnerState({}); return; } const input = e.target; - const isAutofocus = !inputRef.current; setTimeout(() => { if (!input || input !== inputRef.current) { return; } - if (isAutofocus) { - state.focusSectionInPosition(0); - } else if ( - // avoid selecting all sections when focusing empty field without value - input.value.length && - Number(input.selectionEnd) - Number(input.selectionStart) === - input.value.length - ) { - setSelectedSections('all'); - } else { - syncSelectionFromDOM(); - } + syncSelectionFromDOM(); }); }, onBlur(e) { + enteredKeys.current = ''; props.onBlur?.(e); - setSelectedSections(-1); + focusManager.focusSection(-1); state.confirmPlaceholder(); }, onKeyDown(e) { @@ -153,34 +294,44 @@ export function useDateFieldProps( if (e.key === 'ArrowLeft') { e.preventDefault(); - state.focusPreviousSection(); + enteredKeys.current = ''; + focusManager.focusPreviousSection(); } else if (e.key === 'ArrowRight') { e.preventDefault(); - state.focusNextSection(); + enteredKeys.current = ''; + focusManager.focusNextSection(); } else if (e.key === 'Home') { e.preventDefault(); - state.decrementToMin(); + enteredKeys.current = ''; + state.decrementToMin(focusManager.activeSectionIndex); } else if (e.key === 'End') { e.preventDefault(); - state.incrementToMax(); + enteredKeys.current = ''; + state.incrementToMax(focusManager.activeSectionIndex); } else if (e.key === 'ArrowUp' && !e.altKey) { e.preventDefault(); - state.increment(); + enteredKeys.current = ''; + state.increment(focusManager.activeSectionIndex); } else if (e.key === 'ArrowDown' && !e.altKey) { e.preventDefault(); - state.decrement(); + enteredKeys.current = ''; + state.decrement(focusManager.activeSectionIndex); } else if (e.key === 'PageUp') { e.preventDefault(); - state.incrementPage(); + enteredKeys.current = ''; + state.incrementPage(focusManager.activeSectionIndex); } else if (e.key === 'PageDown') { e.preventDefault(); - state.decrementPage(); + enteredKeys.current = ''; + state.decrementPage(focusManager.activeSectionIndex); } else if (e.key === 'Backspace' || e.key === 'Delete') { e.preventDefault(); - state.clearSection(); - } else if (e.key === 'a' && (e['ctrlKey'] || e['metaKey'])) { + enteredKeys.current = ''; + backspace(); + } else if (e.key === 'a' && e[CtrlCmd]) { e.preventDefault(); - setSelectedSections('all'); + enteredKeys.current = ''; + focusManager.focusSection('all'); } }, onKeyUp: props.onKeyUp, @@ -200,9 +351,19 @@ export function useDateFieldProps( }, onBeforeInput(e) { e.preventDefault(); - const key = e.data; - if (key !== undefined && key !== null) { - state.onInput(key); + switch (e.nativeEvent.inputType) { + case 'deleteContentBackward': + case 'deleteContentForward': { + enteredKeys.current = ''; + backspace(); + break; + } + default: { + const key = e.data; + if (key !== undefined && key !== null) { + onInput(key); + } + } } }, onPaste(e: React.ClipboardEvent) { @@ -211,14 +372,14 @@ export function useDateFieldProps( return; } + enteredKeys.current = ''; const pastedValue = cleanString(e.clipboardData.getData('text')); if ( - state.selectedSectionIndexes && - state.selectedSectionIndexes.startIndex === - state.selectedSectionIndexes.endIndex + focusManager.selectedSectionIndexes && + focusManager.selectedSectionIndexes.startIndex === + focusManager.selectedSectionIndexes.endIndex ) { - const activeSection = - state.sections[state.selectedSectionIndexes.startIndex]; + const activeSection = state.sections[focusManager.activeSectionIndex]; const digitsOnly = /^\d+$/.test(pastedValue); const lettersOnly = /^[a-zA-Z]+$/.test(pastedValue); @@ -229,7 +390,7 @@ export function useDateFieldProps( (activeSection.contentType === 'letter' && lettersOnly)), ); if (isValidValue) { - state.onInput(pastedValue); + onInput(pastedValue); return; } if (digitsOnly || lettersOnly) { @@ -243,3 +404,25 @@ export function useDateFieldProps( }, }; } + +function prepareState(sections: DateFieldSection[]) { + let position = 1; + let text = ''; + const positions: {start: number; end: number}[] = new Array(sections.length); + for (let i = 0; i < sections.length; i++) { + const section = sections[i]; + if (!section) { + throw new Error('Section must be defined'); + } + + // use bidirectional context to allow the browser autodetect text direction + const textValue = '\u2068' + section.textValue + '\u2069'; + text += textValue; + const pos = {start: position, end: position + textValue.length}; + position += textValue.length; + positions[i] = pos; + } + // use ltr direction context to get predictable navigation inside input + text = '\u2066' + text + '\u2069'; + return {text, positions}; +} diff --git a/src/components/DateField/hooks/useDateFieldState.ts b/src/components/DateField/hooks/useDateFieldState.ts index 086be492..bd637f36 100644 --- a/src/components/DateField/hooks/useDateFieldState.ts +++ b/src/components/DateField/hooks/useDateFieldState.ts @@ -7,12 +7,13 @@ import type {InputBase, Validation, ValueBase} from '../../types'; import {createPlaceholderValue, isInvalid} from '../../utils/dates'; import {useDefaultTimeZone} from '../../utils/useDefaultTimeZone'; import {IncompleteDate} from '../IncompleteDate'; -import type {DateFieldSectionWithoutPosition} from '../types'; +import type {FormatSection} from '../types'; import { addSegment, adjustDateToFormat, getEditableSections, getFormatInfo, + isEditableSectionType, parseDateFromString, setSegment, useFormatSections, @@ -106,38 +107,6 @@ export function useDateFieldState(props: DateFieldStateOptions): DateFieldState const sectionsState = useSectionsState(sections, displayValue, dateValue); - const [selectedSections, setSelectedSections] = React.useState(-1); - - const selectedSectionIndexes = React.useMemo<{ - startIndex: number; - endIndex: number; - } | null>(() => { - if (selectedSections === -1) { - return null; - } - - if (selectedSections === 'all') { - return { - startIndex: 0, - endIndex: sectionsState.editableSections.length - 1, - }; - } - - if (typeof selectedSections === 'number') { - return {startIndex: selectedSections, endIndex: selectedSections}; - } - - if (typeof selectedSections === 'string') { - const selectedSectionIndex = sectionsState.editableSections.findIndex( - (section) => section.type === selectedSections, - ); - - return {startIndex: selectedSectionIndex, endIndex: selectedSectionIndex}; - } - - return selectedSections; - }, [selectedSections, sectionsState.editableSections]); - function setValue(newValue: DateTime | IncompleteDate | null) { if (props.disabled || props.readOnly) { return; @@ -181,12 +150,11 @@ export function useDateFieldState(props: DateFieldStateOptions): DateFieldState } } - function getSectionValue(_sectionIndex: number) { - return displayValue; - } - - function setSectionValue(_sectionIndex: number, newValue: IncompleteDate) { - setValue(newValue); + function clearSection(sectionIndex: number) { + const section = sectionsState.editableSections[sectionIndex]; + if (section && isEditableSectionType(section.type)) { + setValue(displayValue.clear(section.type)); + } } function setValueFromString(str: string) { @@ -222,47 +190,39 @@ export function useDateFieldState(props: DateFieldStateOptions): DateFieldState return useBaseDateFieldState({ value, displayValue: dateValue, - placeholderValue: props.placeholderValue, - timeZone, validationState, editableSections: sectionsState.editableSections, formatInfo, readOnly: props.readOnly, disabled: props.disabled, - selectedSectionIndexes, - selectedSections, isEmpty: displayValue.isCleared(allSegments), - setSelectedSections, setValue, adjustSection, setSection, - getSectionValue, - setSectionValue, + clearSection, setValueFromString, confirmPlaceholder, }); } -function useSectionsState( - sections: DateFieldSectionWithoutPosition[], - value: IncompleteDate, - placeholder: DateTime, -) { +function useSectionsState(sections: FormatSection[], value: IncompleteDate, placeholder: DateTime) { const [state, setState] = React.useState(() => { + const editableSections = getEditableSections(sections, value, placeholder); return { value, sections, placeholder, - editableSections: getEditableSections(sections, value, placeholder), + editableSections, }; }); if (sections !== state.sections || placeholder !== state.placeholder || value !== state.value) { + const editableSections = getEditableSections(sections, value, placeholder); setState({ value, sections, placeholder, - editableSections: getEditableSections(sections, value, placeholder), + editableSections, }); } diff --git a/src/components/DateField/hooks/useFocusManager.ts b/src/components/DateField/hooks/useFocusManager.ts new file mode 100644 index 00000000..8de6ec61 --- /dev/null +++ b/src/components/DateField/hooks/useFocusManager.ts @@ -0,0 +1,160 @@ +import React from 'react'; + +import type {DateFieldSection, DateFieldSectionType} from '../types'; +import {isEditableSectionType} from '../utils'; + +interface FocusManagerOptions { + sections: DateFieldSection[]; +} + +interface FocusManager { + /** Active section index */ + activeSectionIndex: number; + /** Selected sections */ + selectedSectionIndexes: {startIndex: number; endIndex: number} | null; + /** Return true if it is the first section */ + isFistSection: (position: number) => boolean; + /** Return true if it is the last section */ + isLastSection: (position: number) => boolean; + /** Focus section in position or all sections. */ + focusSection: (position: number | 'all') => void; + /** Focuses the next section if present */ + focusNextSection: () => void; + /** Focuses the previous section if present */ + focusPreviousSection: () => void; + /** Focuses the first section */ + focusFirstSection: () => void; + /** Focuses the last section */ + focusLastSection: () => void; +} + +export function useFocusManager({sections}: FocusManagerOptions): FocusManager { + const [selectedSections, setSelectedSections] = React.useState(-1); + + const connections = React.useMemo(() => connectEditableSections(sections), [sections]); + + const selectedSectionIndexes = React.useMemo<{ + startIndex: number; + endIndex: number; + } | null>(() => { + if (selectedSections === -1) { + return null; + } + + if (selectedSections === 'all') { + return { + startIndex: 0, + endIndex: connections.length - 1, + }; + } + + if (typeof selectedSections === 'number') { + return {startIndex: selectedSections, endIndex: selectedSections}; + } + + return selectedSections; + }, [selectedSections, connections]); + + const activeSectionIndex = getCurrentEditableSectionIndex(connections, selectedSections); + + return { + activeSectionIndex, + selectedSectionIndexes, + isFistSection(position) { + const p = connections[position]; + if (!p) { + return false; + } + return position === p.previousEditableSection; + }, + isLastSection(position) { + const p = connections[position]; + if (!p) { + return false; + } + return position === p.nextEditableSection; + }, + focusSection(index: number | 'all') { + if (index === 'all' || index === -1) { + setSelectedSections(index); + } else { + setSelectedSections(getCurrentEditableSectionIndex(connections, index)); + } + }, + focusNextSection() { + const currentIndex = selectedSections === 'all' ? 0 : selectedSections; + const newIndex = connections[currentIndex]?.nextEditableSection ?? -1; + if (newIndex !== -1) { + setSelectedSections(newIndex); + } + }, + focusPreviousSection() { + const currentIndex = selectedSections === 'all' ? 0 : selectedSections; + const newIndex = connections[currentIndex]?.previousEditableSection ?? -1; + if (newIndex !== -1) { + setSelectedSections(newIndex); + } + }, + focusFirstSection() { + const newIndex = connections[0]?.previousEditableSection ?? -1; + if (newIndex !== -1) { + setSelectedSections(newIndex); + } + }, + focusLastSection() { + const newIndex = connections[sections.length - 1]?.nextEditableSection ?? -1; + if (newIndex !== -1) { + setSelectedSections(newIndex); + } + }, + }; +} + +interface Connection { + type: DateFieldSectionType; + previousEditableSection: number; + nextEditableSection: number; +} + +function connectEditableSections(sections: DateFieldSection[]) { + let previousEditableSection = -1; + const connections: Connection[] = new Array(sections.length); + for (let i = 0; i < sections.length; i++) { + const section = sections[i]; + if (!section) { + throw new Error('section must be defined'); + } + + const connection = { + type: section.type, + previousEditableSection, + nextEditableSection: previousEditableSection, + }; + connections[i] = connection; + + if (isEditableSectionType(connection.type)) { + for (let j = Math.max(0, previousEditableSection); j <= i; j++) { + const prevSection = connections[j]; + if (prevSection) { + prevSection.nextEditableSection = i; + if (prevSection.previousEditableSection === -1) { + prevSection.previousEditableSection = i; + } + } + } + previousEditableSection = i; + } + } + + return connections; +} + +function getCurrentEditableSectionIndex(sections: Connection[], selectedSections: 'all' | number) { + const currentIndex = + selectedSections === 'all' || selectedSections === -1 ? 0 : selectedSections; + const section = sections[currentIndex]; + if (section && !isEditableSectionType(section.type)) { + return section.nextEditableSection; + } + return isEditableSectionType(section?.type) ? currentIndex : -1; +} diff --git a/src/components/RangeDateField/hooks/useRangeDateFieldState.ts b/src/components/DateField/hooks/useRangeDateFieldState.ts similarity index 83% rename from src/components/RangeDateField/hooks/useRangeDateFieldState.ts rename to src/components/DateField/hooks/useRangeDateFieldState.ts index fed4cb52..704be091 100644 --- a/src/components/RangeDateField/hooks/useRangeDateFieldState.ts +++ b/src/components/DateField/hooks/useRangeDateFieldState.ts @@ -3,23 +3,26 @@ import React from 'react'; import type {DateTime} from '@gravity-ui/date-utils'; import {useControlledState, useLang} from '@gravity-ui/uikit'; -import {useBaseDateFieldState} from '../../DateField'; -import type {DateFieldState} from '../../DateField'; -import {IncompleteDate} from '../../DateField/IncompleteDate.js'; -import type {DateFieldSectionWithoutPosition} from '../../DateField/types'; +import type {DateFieldBase} from '../../types/datePicker'; +import type {RangeValue} from '../../types/inputs'; +import {createPlaceholderRangeValue, isInvalid} from '../../utils/dates'; +import {useDefaultTimeZone} from '../../utils/useDefaultTimeZone'; +import {IncompleteDate} from '../IncompleteDate.js'; +import type {FormatSection} from '../types'; import { addSegment, adjustDateToFormat, + getEditableSections, getFormatInfo, + isEditableSectionType, parseDateFromString, setSegment, + toEditableSection, useFormatSections, -} from '../../DateField/utils'; -import type {DateFieldBase} from '../../types/datePicker'; -import type {RangeValue} from '../../types/inputs'; -import {isInvalid} from '../../utils/dates'; -import {useDefaultTimeZone} from '../../utils/useDefaultTimeZone'; -import {createPlaceholderRangeValue, getRangeEditableSections, isValidRange} from '../utils'; +} from '../utils'; + +import type {DateFieldState} from './useBaseDateFieldState'; +import {useBaseDateFieldState} from './useBaseDateFieldState'; export interface RangeDateFieldStateOptions extends DateFieldBase> { delimiter?: string; @@ -102,38 +105,6 @@ export function useRangeDateFieldState(props: RangeDateFieldStateOptions): Range const sectionsState = useSectionsState(sections, displayValue, rangeValue, delimiter); - const [selectedSections, setSelectedSections] = React.useState(-1); - - const selectedSectionIndexes = React.useMemo<{ - startIndex: number; - endIndex: number; - } | null>(() => { - if (selectedSections === -1) { - return null; - } - - if (selectedSections === 'all') { - return { - startIndex: 0, - endIndex: sectionsState.editableSections.length - 1, - }; - } - - if (typeof selectedSections === 'number') { - return {startIndex: selectedSections, endIndex: selectedSections}; - } - - if (typeof selectedSections === 'string') { - const selectedSectionIndex = sectionsState.editableSections.findIndex( - (section) => section.type === selectedSections, - ); - - return {startIndex: selectedSectionIndex, endIndex: selectedSectionIndex}; - } - - return selectedSections; - }, [selectedSections, sectionsState.editableSections]); - function setValue(newValue: RangeValue | RangeValue | null) { if (props.disabled || props.readOnly) { return; @@ -208,14 +179,15 @@ export function useRangeDateFieldState(props: RangeDateFieldStateOptions): Range } } - function getSectionValue(sectionIndex: number) { - const portion = sectionIndex <= sections.length ? 'start' : 'end'; - return displayValue[portion]; - } - - function setSectionValue(sectionIndex: number, currentValue: IncompleteDate) { + function clearSection(sectionIndex: number) { + const section = sectionsState.editableSections[sectionIndex]; const portion = sectionIndex <= sections.length ? 'start' : 'end'; - setValue({...displayValue, [portion]: currentValue}); + if (section && isEditableSectionType(section.type)) { + setValue({ + ...displayValue, + [portion]: displayValue[portion].clear(section.type), + }); + } } function setValueFromString(str: string) { @@ -273,30 +245,24 @@ export function useRangeDateFieldState(props: RangeDateFieldStateOptions): Range return useBaseDateFieldState, RangeValue>({ value, displayValue: rangeValue, - placeholderValue: props.placeholderValue, - timeZone, validationState, editableSections: sectionsState.editableSections, formatInfo, readOnly: props.readOnly, disabled: props.disabled, - selectedSectionIndexes, - selectedSections, isEmpty: displayValue.start.isCleared(allSegments) && displayValue.end.isCleared(allSegments), - setSelectedSections, setValue, adjustSection, setSection, - getSectionValue, - setSectionValue, + clearSection, setValueFromString, confirmPlaceholder, }); } function useSectionsState( - sections: DateFieldSectionWithoutPosition[], + sections: FormatSection[], value: RangeValue, placeholder: RangeValue, delimiter: string, @@ -331,3 +297,33 @@ function useSectionsState( return state; } + +function getRangeEditableSections( + sections: FormatSection[], + value: RangeValue, + placeholder: RangeValue, + delimiter: string, +) { + const start = getEditableSections(sections, value.start, placeholder.start); + const end = getEditableSections(sections, value.end, placeholder.end); + + const delimiterSection = toEditableSection( + { + type: 'literal', + contentType: 'letter', + format: delimiter, + placeholder: delimiter, + hasLeadingZeros: false, + }, + value.start, + placeholder.start, + ); + + const editableSections = [...start, delimiterSection, ...end]; + + return editableSections; +} + +function isValidRange({start, end}: RangeValue): boolean { + return start.isValid() && end.isValid() && (start.isSame(end) || start.isBefore(end)); +} diff --git a/src/components/DateField/index.ts b/src/components/DateField/index.ts index e941dcfd..208cfc9f 100644 --- a/src/components/DateField/index.ts +++ b/src/components/DateField/index.ts @@ -1,5 +1,7 @@ export * from './DateField'; export * from './hooks/useDateFieldState'; +export * from './hooks/useRangeDateFieldState'; export * from './hooks/useDateFieldProps'; -export * from './hooks/useBaseDateFieldState'; + +export type {DateFieldState} from './hooks/useBaseDateFieldState'; diff --git a/src/components/DateField/types.ts b/src/components/DateField/types.ts index d918e3d5..78de41dc 100644 --- a/src/components/DateField/types.ts +++ b/src/components/DateField/types.ts @@ -59,40 +59,12 @@ export interface DateFieldSection { * For example, the value `1` should be rendered as "01" instead of "1". */ hasLeadingZeros: boolean; - /** - * If `true`, the section value has been modified since the last time the sections were generated from a valid date. - * When we can generate a valid date from the section, we don't directly pass it to `onChange`, - * Otherwise, we would lose all the information contained in the original date, things like: - * - time if the format does not contain it - * - timezone / UTC - * - * To avoid losing that information, we transfer the values of the modified sections from the newly generated date to the original date. - */ - modified: boolean; - /** - * Start index of the section in the format - */ - start: number; - /** - * End index of the section in the format - */ - end: number; - - previousEditableSection: number; - nextEditableSection: number; } -export type DateFieldSectionWithoutPosition = - Omit< - TSection, - | 'start' - | 'end' - | 'value' - | 'textValue' - | 'modified' - | 'previousEditableSection' - | 'nextEditableSection' - >; +export type FormatSection = Omit< + TSection, + 'value' | 'textValue' +>; export type AvailableSections = Partial>; diff --git a/src/components/DateField/utils.ts b/src/components/DateField/utils.ts index 1c7fa767..96e147d5 100644 --- a/src/components/DateField/utils.ts +++ b/src/components/DateField/utils.ts @@ -15,9 +15,9 @@ import type { AvailableSections, DateFieldSection, DateFieldSectionType, - DateFieldSectionWithoutPosition, DateFormatTokenMap, FormatInfo, + FormatSection, } from './types'; export const EDITABLE_SEGMENTS = { @@ -136,11 +136,7 @@ function isHour12(format: string) { return dateTime().set('hour', 15).format(format) !== '15'; } -function getSectionLimits( - section: DateFieldSectionWithoutPosition, - date: IncompleteDate, - placeholder: DateTime, -) { +function getSectionLimits(section: FormatSection, date: IncompleteDate, placeholder: DateTime) { const {type, format} = section; switch (type) { case 'year': { @@ -195,7 +191,7 @@ function getSectionLimits( return {}; } -function getSectionValue(section: DateFieldSectionWithoutPosition, date: IncompleteDate) { +function getSectionValue(section: FormatSection, date: IncompleteDate) { const type = section.type; switch (type) { case 'year': { @@ -335,7 +331,7 @@ export function addSegment( } export function setSegment( - section: DateFieldSectionWithoutPosition, + section: FormatSection, date: IncompleteDate, amount: number, placeholder: DateTime, @@ -530,7 +526,7 @@ function getSectionPlaceholder( type TranslateFunction = ExtractFunctionType; export function splitFormatIntoSections(format: string, t: TranslateFunction = i18n, lang = 'en') { - const sections: DateFieldSectionWithoutPosition[] = []; + const sections: FormatSection[] = []; const localeFormats = dayjs.Ls[lang].formats as LongDateFormat; const expandedFormat = expandFormat(format, localeFormats); @@ -578,7 +574,7 @@ export function splitFormatIntoSections(format: string, t: TranslateFunction = i } function addFormatSection( - sections: DateFieldSectionWithoutPosition[], + sections: FormatSection[], token: string, t: TranslateFunction, lang: string, @@ -604,7 +600,7 @@ function addFormatSection( }); } -function addLiteralSection(sections: DateFieldSectionWithoutPosition[], token: string) { +function addLiteralSection(sections: FormatSection[], token: string) { if (!token) { return; } @@ -619,7 +615,7 @@ function addLiteralSection(sections: DateFieldSectionWithoutPosition[], token: s } function getSectionOptions( - section: Pick, + section: Pick, token: string, lang: string, ) { @@ -663,46 +659,11 @@ export function cleanString(dirtyString: string) { } export function getEditableSections( - sections: DateFieldSectionWithoutPosition[], + sections: FormatSection[], value: IncompleteDate, placeholder: DateTime, ) { - let position = 1; - const newSections: DateFieldSection[] = []; - let previousEditableSection = -1; - for (let i = 0; i < sections.length; i++) { - const section = sections[i]; - if (!section) { - continue; - } - - const newSection = toEditableSection( - section, - value, - placeholder, - position, - previousEditableSection, - ); - - newSections.push(newSection); - - if (isEditableSectionType(section.type)) { - for (let j = Math.max(0, previousEditableSection); j <= i; j++) { - const prevSection = newSections[j]; - if (prevSection) { - prevSection.nextEditableSection = i; - if (prevSection.previousEditableSection === -1) { - prevSection.previousEditableSection = i; - } - } - } - previousEditableSection = i; - } - - position += newSection.textValue.length; - } - - return newSections; + return sections.map((section) => toEditableSection(section, value, placeholder)); } export function isEditableSectionType( @@ -712,11 +673,9 @@ export function isEditableSectionType( } export function toEditableSection( - section: DateFieldSectionWithoutPosition, + section: FormatSection, value: IncompleteDate, placeholder: DateTime, - position: number, - previousEditableSection: number, ): DateFieldSection { let renderedValue = section.placeholder; let val = isEditableSectionType(section.type) ? value[section.type] : null; @@ -744,44 +703,16 @@ export function toEditableSection( } } - // use bidirectional context to allow the browser autodetect text direction - renderedValue = '\u2068' + renderedValue + '\u2069'; - - const sectionLength = renderedValue.length; - const newSection = { ...section, value: getSectionValue(section, value), textValue: renderedValue, - start: position, - end: position + sectionLength, - modified: false, - previousEditableSection, - nextEditableSection: previousEditableSection, ...getSectionLimits(section, value, placeholder), }; return newSection; } -export function getCurrentEditableSectionIndex( - sections: DateFieldSection[], - selectedSections: 'all' | number, -) { - const currentIndex = - selectedSections === 'all' || selectedSections === -1 ? 0 : selectedSections; - const section = sections[currentIndex]; - if (section && !isEditableSectionType(section.type)) { - return section.nextEditableSection; - } - return section ? currentIndex : -1; -} - -export function formatSections(sections: DateFieldSection[]): string { - // use ltr direction context to get predictable navigation inside input - return '\u2066' + sections.map((s) => s.textValue).join('') + '\u2069'; -} - function parseDate(options: {input: string; format: string; timeZone?: string}) { let date = dateTime(options); if (!date.isValid()) { @@ -833,7 +764,7 @@ export function useFormatSections(format: string) { const dateUnits = ['day', 'month', 'quarter', 'year'] satisfies DateFieldSectionType[]; const timeUnits = ['second', 'minute', 'hour'] satisfies DateFieldSectionType[]; -export function getFormatInfo(sections: DateFieldSectionWithoutPosition[]): FormatInfo { +export function getFormatInfo(sections: FormatSection[]): FormatInfo { const availableUnits: AvailableSections = {}; let hasDate = false; let hasTime = false; diff --git a/src/components/RangeDateField/RangeDateField.tsx b/src/components/RangeDateField/RangeDateField.tsx index 0e800226..542d962a 100644 --- a/src/components/RangeDateField/RangeDateField.tsx +++ b/src/components/RangeDateField/RangeDateField.tsx @@ -8,12 +8,11 @@ import {TextInput, useFocusWithin} from '@gravity-ui/uikit'; import {block} from '../../utils/cn'; import {useDateFieldProps} from '../DateField/hooks/useDateFieldProps'; import type {DateFieldProps} from '../DateField/hooks/useDateFieldProps'; +import {useRangeDateFieldState} from '../DateField/hooks/useRangeDateFieldState'; import {HiddenInput} from '../HiddenInput/HiddenInput'; import type {RangeValue} from '../types'; import {filterDOMProps} from '../utils/filterDOMProps'; -import {useRangeDateFieldState} from './hooks/useRangeDateFieldState'; - import './RangeDateField.scss'; const b = block('range-date-field'); diff --git a/src/components/RangeDateField/__tests__/RangeDateField.test.tsx b/src/components/RangeDateField/__tests__/RangeDateField.test.tsx index 01af393f..008f2cf6 100644 --- a/src/components/RangeDateField/__tests__/RangeDateField.test.tsx +++ b/src/components/RangeDateField/__tests__/RangeDateField.test.tsx @@ -4,9 +4,160 @@ import {userEvent} from 'vitest/browser'; import {render} from '#test-utils/utils'; +import {cleanString} from '../../DateField/utils.js'; +import {isMac} from '../../utils/constants.js'; import {RangeDateField} from '../RangeDateField'; describe('RangeDateField', () => { + function getSelectAllShortcut() { + return isMac ? '{Meta>}a{/Meta}' : '{Control>}a{/Control}'; + } + + it('should display the correct range', async () => { + const timeZone = 'Israel'; + const screen = await render( + , + ); + + const input = screen.getByRole('textbox').first().element() as HTMLInputElement; + expect(cleanString(input.value)).toBe('20.01.2024 — 24.01.2024'); + }); + + it('should navigate through the range and change sections', async () => { + const timeZone = 'Israel'; + const screen = await render( + , + ); + const input = screen.getByRole('textbox').first().element() as HTMLInputElement; + + expect(cleanString(input.value)).toBe('DD.MM.YYYY — DD.MM.YYYY'); + + await userEvent.keyboard('{Tab}'); + await userEvent.keyboard('{ArrowUp}{ArrowRight}{PageUp}{PageUp}'); + await userEvent.keyboard('{ArrowRight}{ArrowRight}{ArrowRight}{ArrowUp}'); + + expect(cleanString(input.value)).toBe('12.03.YYYY — DD.01.YYYY'); + }); + + it('should call onUpdate only if the entire value is valid', async () => { + const onUpdateSpy = vi.fn(); + const timeZone = 'Israel'; + const screen = await render( + , + ); + const input = screen.getByRole('textbox').first().element() as HTMLInputElement; + + await userEvent.keyboard('{Tab}'); + await userEvent.keyboard('3104202431042024'); + + expect(onUpdateSpy).not.toHaveBeenCalled(); + expect(cleanString(input.value)).toBe('31.04.2024 — 31.04.2024'); + + await userEvent.keyboard('{Tab}'); + + expect(onUpdateSpy).toHaveBeenCalledWith({ + start: dateTime({input: '2024-04-30T00:00:00', timeZone}).startOf('day'), + end: dateTime({input: '2024-04-30T00:00:00', timeZone}).endOf('day'), + }); + }); + + it('should set a range from the string', async () => { + const screen = await render( +
+ + +
, + ); + const input = screen.getByLabelText('target').element() as HTMLInputElement; + const source = screen.getByLabelText('source').element() as HTMLInputElement; + + await userEvent.click(source); + await userEvent.keyboard(getSelectAllShortcut()); + await userEvent.copy(); + await userEvent.click(input); + await userEvent.paste(); + + expect(cleanString(input.value)).toBe('31.01.2024 — 29.02.2024'); + }); + + it('should call custom parseDateFromString when provided for range dates', async () => { + const customParser = vi + .fn() + .mockReturnValueOnce(dateTime({input: '2024-01-15T00:00:00Z'})) + .mockReturnValueOnce(dateTime({input: '2024-01-20T00:00:00Z'})); + const screen = await render( +
+ + +
, + ); + const input = screen.getByLabelText('target').element() as HTMLInputElement; + const source = screen.getByLabelText('source').element() as HTMLInputElement; + + await userEvent.click(source); + await userEvent.keyboard(getSelectAllShortcut()); + await userEvent.copy(); + await userEvent.click(input); + await userEvent.paste(); + + expect(cleanString(input.value)).toBe('15.01.2024 — 20.01.2024'); + expect(customParser).toHaveBeenCalledTimes(2); + expect(customParser).toHaveBeenNthCalledWith(1, '15.01.2024', 'DD.MM.YYYY', 'default'); + expect(customParser).toHaveBeenNthCalledWith(2, '20.01.2024', 'DD.MM.YYYY', 'default'); + }); + + it('should clear the section or the entire range', async () => { + const screen = await render( + , + ); + const input = screen.getByRole('textbox').element() as HTMLInputElement; + + expect(cleanString(input.value)).toBe('20.01.2024 — 24.01.2024'); + + await userEvent.keyboard('{Tab}'); + await userEvent.keyboard('{ArrowRight}{ArrowRight}{ArrowRight}{ArrowRight}'); + await userEvent.keyboard('{Backspace}'); + + expect(cleanString(input.value)).toBe('20.01.2024 — 24.MM.2024'); + + await userEvent.keyboard(getSelectAllShortcut()); + await userEvent.keyboard('{Backspace}'); + + expect(cleanString(input.value)).toBe('DD.MM.YYYY — DD.MM.YYYY'); + }); + describe('invalid date', () => { it('should allow to enter 31 april - 31 april and constrains the range on blur', async () => { const onUpdate = vi.fn(); diff --git a/src/components/RangeDateField/__tests__/parseDateFromString.test.ts b/src/components/RangeDateField/__tests__/parseDateFromString.test.ts deleted file mode 100644 index a0b1497a..00000000 --- a/src/components/RangeDateField/__tests__/parseDateFromString.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import {dateTime} from '@gravity-ui/date-utils'; -import {beforeEach, describe, expect, it, vitest} from 'vitest'; -import type {MockedFunction} from 'vitest'; - -import {renderHook} from '#test-utils/utils'; - -import {parseDateFromString} from '../../DateField/utils'; -import {useRangeDateFieldState} from '../hooks/useRangeDateFieldState'; - -vitest.mock('../../DateField/utils', async () => ({ - ...(await vitest.importActual('../../DateField/utils')), - parseDateFromString: vitest.fn(), -})); - -const mockedParseDateFromString = parseDateFromString as MockedFunction; - -describe('RangeDateField: parseDateFromString', () => { - beforeEach(() => { - vitest.clearAllMocks(); - mockedParseDateFromString.mockImplementation((str, format, timeZone) => { - return dateTime({input: str, format, timeZone}); - }); - }); - - it('should call custom parseDateFromString when provided for range dates', async () => { - const customParser = vitest - .fn() - .mockReturnValueOnce(dateTime({input: '2024-01-15T00:00:00Z'})) - .mockReturnValueOnce(dateTime({input: '2024-01-20T00:00:00Z'})); - - const {result, act} = await renderHook(() => - useRangeDateFieldState({ - format: 'DD.MM.YYYY', - parseDateFromString: customParser, - }), - ); - - act(() => { - result.current.setValueFromString('15.01.2024 — 20.01.2024'); - }); - - expect(customParser).toHaveBeenCalledTimes(2); - expect(customParser).toHaveBeenNthCalledWith(1, '15.01.2024', 'DD.MM.YYYY', 'default'); - expect(customParser).toHaveBeenNthCalledWith(2, '20.01.2024', 'DD.MM.YYYY', 'default'); - expect(mockedParseDateFromString).not.toHaveBeenCalled(); - }); - - it('should use default parseDateFromString when parseDateFromString is not provided', async () => { - const validStartDate = dateTime({input: '2024-01-15T00:00:00Z'}); - const validEndDate = dateTime({input: '2024-01-20T00:00:00Z'}); - mockedParseDateFromString - .mockReturnValueOnce(validStartDate) - .mockReturnValueOnce(validEndDate); - - const {result, act} = await renderHook(() => - useRangeDateFieldState({format: 'DD.MM.YYYY'}), - ); - - act(() => { - result.current.setValueFromString('15.01.2024 — 20.01.2024'); - }); - - expect(mockedParseDateFromString).toHaveBeenCalledTimes(2); - expect(mockedParseDateFromString).toHaveBeenNthCalledWith( - 1, - '15.01.2024', - 'DD.MM.YYYY', - 'default', - ); - expect(mockedParseDateFromString).toHaveBeenNthCalledWith( - 2, - '20.01.2024', - 'DD.MM.YYYY', - 'default', - ); - }); -}); diff --git a/src/components/RangeDateField/__tests__/utils.test.ts b/src/components/RangeDateField/__tests__/utils.test.ts deleted file mode 100644 index 79bbd5cc..00000000 --- a/src/components/RangeDateField/__tests__/utils.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import {dateTime} from '@gravity-ui/date-utils'; -import {expect, test} from 'vitest'; - -import {IncompleteDate} from '../../DateField/IncompleteDate'; -import { - cleanString, - formatSections, - isEditableSectionType, - splitFormatIntoSections, -} from '../../DateField/utils'; -import {getRangeEditableSections} from '../utils'; - -test('create a valid sequence of editable sections for range', () => { - const format = 'DD.MM.YYYY hh:mm:ss'; - const sections = splitFormatIntoSections(format); - const date = dateTime({input: [2024, 1, 14, 12, 30, 0]}); - const range = {start: date, end: date}; - const incompleteRange = { - start: new IncompleteDate().set('month', range.start.month() + 1), - end: new IncompleteDate().set('year', range.end.year()), - }; - const eSections = getRangeEditableSections(sections, incompleteRange, range, ' — '); - - expect(eSections.length).toBe(23); - - const indexes = eSections - .map((s, i) => (isEditableSectionType(s.type) ? i : null)) - .filter((e) => e !== null); - - // eslint-disable-next-line no-nested-ternary - const fixIndex = (i: number) => (i < 0 ? 0 : i >= indexes.length ? indexes.length - 1 : i); - - let pointer = -1; - let position = 1; - for (let i = 0; i < eSections.length; i++) { - const section = eSections[i]; - if (isEditableSectionType(section.type)) { - pointer++; - } - - const previous = indexes[pointer] === i ? indexes[fixIndex(pointer - 1)] : indexes[pointer]; - const next = indexes[fixIndex(pointer + 1)]; - - expect(section.previousEditableSection).toBe(previous); - expect(section.nextEditableSection).toBe(next); - expect(section.start).toBe(position); - - position = section.end; - } - - expect(cleanString(formatSections(eSections))).toBe( - 'DD.02.YYYY hh:mm:ss — DD.MM.2024 hh:mm:ss', - ); -}); diff --git a/src/components/RangeDateField/hooks/useRangeDateFieldState.test.ts b/src/components/RangeDateField/hooks/useRangeDateFieldState.test.ts deleted file mode 100644 index 8180d677..00000000 --- a/src/components/RangeDateField/hooks/useRangeDateFieldState.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -import {dateTime} from '@gravity-ui/date-utils'; -import type {DateTime} from '@gravity-ui/date-utils'; -import {expect, test, vitest} from 'vitest'; - -import {renderHook} from '#test-utils/utils'; - -import {cleanString, isEditableSectionType} from '../../DateField/utils'; -import type {RangeValue} from '../../types'; - -import {useRangeDateFieldState} from './useRangeDateFieldState'; - -test('can display the correct range', async () => { - const timeZone = 'Israel'; - const {result} = await renderHook(() => - useRangeDateFieldState({ - format: 'DD.MM.YYYY', - placeholderValue: dateTime({input: '2024-01-12T00:00:00', timeZone}), - timeZone, - value: { - start: dateTime({input: '2024-01-20T12:30:00', timeZone}), - end: dateTime({input: '2024-01-24T12:00:00', timeZone}), - }, - }), - ); - - const {text} = result.current; - expect(cleanString(text)).toBe('20.01.2024 — 24.01.2024'); -}); - -test('can navigate through the range and change sections', async () => { - const timeZone = 'Israel'; - const {result, act} = await renderHook(() => - useRangeDateFieldState({ - format: 'DD.MM.YYYY', - placeholderValue: dateTime({input: '2024-01-12T00:00:00', timeZone}), - timeZone, - }), - ); - - expect(cleanString(result.current.text)).toBe('DD.MM.YYYY — DD.MM.YYYY'); - - act(() => result.current.focusFirstSection()); - act(() => result.current.increment()); - act(() => result.current.focusNextSection()); - act(() => result.current.incrementPage()); - act(() => result.current.incrementPage()); - - const position = result.current.sections.filter((e) => isEditableSectionType(e.type))[4]?.end; - - act(() => result.current.focusSectionInPosition(position)); - act(() => result.current.increment()); - - expect(cleanString(result.current.text)).toBe('12.03.YYYY — DD.01.YYYY'); -}); - -test('call onUpdate only if the entire value is valid', async () => { - const onUpdateSpy = vitest.fn(); - const timeZone = 'Israel'; - const {result, act} = await renderHook(() => - useRangeDateFieldState({ - format: 'DD.MM.YYYY', - placeholderValue: dateTime({input: '2024-01-12T00:00:00', timeZone}), - timeZone, - onUpdate: onUpdateSpy, - }), - ); - - act(() => result.current.focusFirstSection()); - act(() => result.current.incrementToMax()); - act(() => result.current.focusNextSection()); - act(() => result.current.increment()); - act(() => result.current.focusNextSection()); - act(() => result.current.increment()); - act(() => result.current.focusNextSection()); - act(() => result.current.increment()); - act(() => result.current.focusNextSection()); - act(() => result.current.increment()); - act(() => result.current.increment()); - act(() => result.current.focusPreviousSection()); - act(() => result.current.incrementToMax()); - - act(() => result.current.focusLastSection()); - act(() => result.current.increment()); - - expect(onUpdateSpy).not.toHaveBeenCalled(); - - expect(cleanString(result.current.text)).toBe('31.01.2024 — 31.02.2024'); - - act(() => result.current.confirmPlaceholder()); - - expect(onUpdateSpy).toHaveBeenCalledWith({ - start: dateTime({input: '2024-01-31T00:00:00', timeZone}).startOf('day'), - end: dateTime({input: '2024-02-29T00:00:00', timeZone}).endOf('day'), - }); -}); - -test('can set a range from the string', async () => { - const {result, act} = await renderHook(() => - useRangeDateFieldState({ - format: 'DD.MM.YYYY', - placeholderValue: dateTime({input: '2024-01-12T00:00:00'}), - }), - ); - - act(() => result.current.setValueFromString('31.01.2024 — 29.02.2024')); - - expect(cleanString(result.current.text)).toBe('31.01.2024 — 29.02.2024'); -}); - -test('can clear the section or the entire range', async () => { - let value: RangeValue | null = { - start: dateTime({input: '2024-01-20T12:30:00'}), - end: dateTime({input: '2024-01-24T12:00:00'}), - }; - - const onUpdate = (newValue: RangeValue | null) => { - value = newValue; - }; - - const {result, act} = await renderHook(() => - useRangeDateFieldState({ - format: 'DD.MM.YYYY', - placeholderValue: dateTime({input: '2024-01-12T00:00:00'}), - value, - onUpdate, - }), - ); - - expect(cleanString(result.current.text)).toBe('20.01.2024 — 24.01.2024'); - - const position = result.current.sections.filter((e) => isEditableSectionType(e.type))[4]?.end; - - act(() => result.current.focusSectionInPosition(position)); - act(() => result.current.clearSection()); - - expect(cleanString(result.current.text)).toBe('20.01.2024 — 24.MM.2024'); - - act(() => result.current.clearAll()); - - expect(cleanString(result.current.text)).toBe('DD.MM.YYYY — DD.MM.YYYY'); -}); diff --git a/src/components/RangeDateField/index.ts b/src/components/RangeDateField/index.ts index a5cccac5..476bad4c 100644 --- a/src/components/RangeDateField/index.ts +++ b/src/components/RangeDateField/index.ts @@ -1,3 +1 @@ export * from './RangeDateField'; - -export * from './hooks/useRangeDateFieldState'; diff --git a/src/components/RangeDateField/utils/createPlaceholderRangeValue.ts b/src/components/RangeDateField/utils/createPlaceholderRangeValue.ts deleted file mode 100644 index 2f1c1faf..00000000 --- a/src/components/RangeDateField/utils/createPlaceholderRangeValue.ts +++ /dev/null @@ -1,7 +0,0 @@ -import {createPlaceholderValue} from '../../utils/dates'; -import type {PlaceholderValueOptions} from '../../utils/dates'; - -export function createPlaceholderRangeValue({placeholderValue, timeZone}: PlaceholderValueOptions) { - const date = createPlaceholderValue({placeholderValue, timeZone}); - return {start: date.startOf('day'), end: date.endOf('day')}; -} diff --git a/src/components/RangeDateField/utils/getRangeEditableSections.ts b/src/components/RangeDateField/utils/getRangeEditableSections.ts deleted file mode 100644 index ea1387db..00000000 --- a/src/components/RangeDateField/utils/getRangeEditableSections.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type {DateTime} from '@gravity-ui/date-utils'; - -import type {IncompleteDate} from '../../DateField/IncompleteDate.js'; -import type {DateFieldSectionWithoutPosition} from '../../DateField/types'; -import {getEditableSections, isEditableSectionType, toEditableSection} from '../../DateField/utils'; -import type {RangeValue} from '../../types'; - -export function getRangeEditableSections( - sections: DateFieldSectionWithoutPosition[], - value: RangeValue, - placeholder: RangeValue, - delimiter: string, -) { - const start = getEditableSections(sections, value.start, placeholder.start); - const end = getEditableSections(sections, value.end, placeholder.end); - - const last = start[start.length - 1]; - let position = last.end; - const previousEditableSection = last.nextEditableSection; - const sectionsCount = start.length + 1; - - const delimiterSection = toEditableSection( - { - type: 'literal', - contentType: 'letter', - format: delimiter, - placeholder: delimiter, - hasLeadingZeros: false, - }, - value.start, - placeholder.start, - position, - previousEditableSection, - ); - - position += delimiterSection.textValue.length - 1; - - let nextEditableSection; - for (let index = 0; index < end.length; index++) { - const section = end[index]; - - section.start += position; - section.end += position; - - if (section.previousEditableSection === 0 && nextEditableSection === undefined) { - section.previousEditableSection = previousEditableSection; - } else { - section.previousEditableSection += sectionsCount; - } - - section.nextEditableSection += sectionsCount; - - if (isEditableSectionType(section.type) && nextEditableSection === undefined) { - nextEditableSection = index + sectionsCount; - } - } - - if (nextEditableSection !== undefined) { - delimiterSection.nextEditableSection = nextEditableSection; - - start[previousEditableSection].nextEditableSection = nextEditableSection; - } - - return [...start, delimiterSection, ...end]; -} diff --git a/src/components/RangeDateField/utils/index.ts b/src/components/RangeDateField/utils/index.ts deleted file mode 100644 index 3f3235d9..00000000 --- a/src/components/RangeDateField/utils/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './getRangeEditableSections'; -export * from './isValidRange'; -export * from './createPlaceholderRangeValue'; diff --git a/src/components/RangeDateField/utils/isValidRange.ts b/src/components/RangeDateField/utils/isValidRange.ts deleted file mode 100644 index ec965da5..00000000 --- a/src/components/RangeDateField/utils/isValidRange.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type {DateTime} from '@gravity-ui/date-utils'; - -import type {RangeValue} from '../../types'; - -export function isValidRange({start, end}: RangeValue): boolean { - return start.isValid() && end.isValid() && (start.isSame(end) || start.isBefore(end)); -} diff --git a/src/components/RangeDatePicker/hooks/useRangeDatePickerState.ts b/src/components/RangeDatePicker/hooks/useRangeDatePickerState.ts index 24b9f662..c646c006 100644 --- a/src/components/RangeDatePicker/hooks/useRangeDatePickerState.ts +++ b/src/components/RangeDatePicker/hooks/useRangeDatePickerState.ts @@ -1,15 +1,14 @@ import type {DateTime} from '@gravity-ui/date-utils'; +import {useRangeDateFieldState} from '../../DateField'; +import type {RangeDateFieldStateOptions} from '../../DateField'; import type {FormatInfo} from '../../DateField/types'; import {adjustDateToFormat} from '../../DateField/utils'; import type {DatePickerState} from '../../DatePicker'; import {datePickerStateFactory} from '../../DatePicker/hooks/datePickerStateFactory'; import {getDateTimeValue} from '../../DatePicker/utils'; -import {useRangeDateFieldState} from '../../RangeDateField'; -import type {RangeDateFieldStateOptions} from '../../RangeDateField'; -import {createPlaceholderRangeValue} from '../../RangeDateField/utils'; import type {RangeValue} from '../../types'; -import {mergeDateTime} from '../../utils/dates'; +import {createPlaceholderRangeValue, mergeDateTime} from '../../utils/dates'; export type RangeDatePickerState = DatePickerState>; diff --git a/src/components/utils/constants.ts b/src/components/utils/constants.ts index 77e47bba..3dce956a 100644 --- a/src/components/utils/constants.ts +++ b/src/components/utils/constants.ts @@ -5,3 +5,12 @@ export const DAY = 24 * HOUR; export const WEEK = 7 * DAY; export const MONTH = 30 * DAY; export const YEAR = 365 * DAY; + +const platform = + typeof window !== 'undefined' && window.navigator + ? // @ts-expect-error + window.navigator['userAgentData']?.platform || window.navigator.platform + : ''; + +export const isMac = /^Mac/i.test(platform); +export const CtrlCmd = isMac ? 'metaKey' : 'ctrlKey'; diff --git a/src/components/utils/dates.ts b/src/components/utils/dates.ts index ad9c7265..fe9c3809 100644 --- a/src/components/utils/dates.ts +++ b/src/components/utils/dates.ts @@ -5,7 +5,7 @@ export function isWeekend(date: DateTime) { return [0, 6].includes(date.day()); } -export interface PlaceholderValueOptions { +interface PlaceholderValueOptions { placeholderValue?: DateTime; timeZone?: string; } @@ -13,6 +13,11 @@ export function createPlaceholderValue({placeholderValue, timeZone}: Placeholder return placeholderValue ?? dateTime({timeZone}).startOf('day'); } +export function createPlaceholderRangeValue({placeholderValue, timeZone}: PlaceholderValueOptions) { + const date = createPlaceholderValue({placeholderValue, timeZone}); + return {start: date.startOf('day'), end: date.endOf('day')}; +} + export function isInvalid( value: DateTime | null | undefined, minValue: DateTime | undefined,