From 2a05c97a46374c16015ddcb0d356b1747dfc510d Mon Sep 17 00:00:00 2001 From: Valerii Sidorenko Date: Sun, 8 Feb 2026 03:54:31 +0100 Subject: [PATCH 1/5] refactor: move selected section state to useBaseDateFieldState --- .../DateField/hooks/useBaseDateFieldState.ts | 32 +++++++++++++---- .../DateField/hooks/useDateFieldState.ts | 35 ------------------- .../hooks/useRangeDateFieldState.ts | 35 ------------------- 3 files changed, 25 insertions(+), 77 deletions(-) diff --git a/src/components/DateField/hooks/useBaseDateFieldState.ts b/src/components/DateField/hooks/useBaseDateFieldState.ts index 7bb83ab..94c912d 100644 --- a/src/components/DateField/hooks/useBaseDateFieldState.ts +++ b/src/components/DateField/hooks/useBaseDateFieldState.ts @@ -22,10 +22,7 @@ export type BaseDateFieldStateOptions = { 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; @@ -109,10 +106,7 @@ export function useBaseDateFieldState( displayValue, editableSections, formatInfo, - selectedSectionIndexes, - selectedSections, isEmpty, - setSelectedSections, setValue, adjustSection, setSection, @@ -122,6 +116,30 @@ export function useBaseDateFieldState( confirmPlaceholder, } = props; + 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: editableSections.length - 1, + }; + } + + if (typeof selectedSections === 'number') { + return {startIndex: selectedSections, endIndex: selectedSections}; + } + + return selectedSections; + }, [selectedSections, editableSections]); + const enteredKeys = React.useRef(''); return { @@ -170,7 +188,7 @@ export function useBaseDateFieldState( focusFirstSection() { const newIndex = this.sections[0]?.previousEditableSection ?? -1; if (newIndex !== -1) { - setSelectedSections(newIndex); + this.setSelectedSections(newIndex); } }, focusLastSection() { diff --git a/src/components/DateField/hooks/useDateFieldState.ts b/src/components/DateField/hooks/useDateFieldState.ts index 086be49..d08b3a4 100644 --- a/src/components/DateField/hooks/useDateFieldState.ts +++ b/src/components/DateField/hooks/useDateFieldState.ts @@ -106,38 +106,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; @@ -229,10 +197,7 @@ export function useDateFieldState(props: DateFieldStateOptions): DateFieldState formatInfo, readOnly: props.readOnly, disabled: props.disabled, - selectedSectionIndexes, - selectedSections, isEmpty: displayValue.isCleared(allSegments), - setSelectedSections, setValue, adjustSection, setSection, diff --git a/src/components/RangeDateField/hooks/useRangeDateFieldState.ts b/src/components/RangeDateField/hooks/useRangeDateFieldState.ts index fed4cb5..419fd87 100644 --- a/src/components/RangeDateField/hooks/useRangeDateFieldState.ts +++ b/src/components/RangeDateField/hooks/useRangeDateFieldState.ts @@ -102,38 +102,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; @@ -280,11 +248,8 @@ export function useRangeDateFieldState(props: RangeDateFieldStateOptions): Range formatInfo, readOnly: props.readOnly, disabled: props.disabled, - selectedSectionIndexes, - selectedSections, isEmpty: displayValue.start.isCleared(allSegments) && displayValue.end.isCleared(allSegments), - setSelectedSections, setValue, adjustSection, setSection, From 8e75f1d65a7d33826250b3e97acc5e9a635897d8 Mon Sep 17 00:00:00 2001 From: Valerii Sidorenko Date: Sun, 8 Feb 2026 03:55:17 +0100 Subject: [PATCH 2/5] refactor: editable section generation --- .../DateField/hooks/useDateFieldState.ts | 9 +++- src/components/DateField/types.ts | 18 +------- src/components/DateField/utils.ts | 39 +++++++--------- .../utils/getRangeEditableSections.ts | 44 ++++--------------- 4 files changed, 31 insertions(+), 79 deletions(-) diff --git a/src/components/DateField/hooks/useDateFieldState.ts b/src/components/DateField/hooks/useDateFieldState.ts index d08b3a4..355baba 100644 --- a/src/components/DateField/hooks/useDateFieldState.ts +++ b/src/components/DateField/hooks/useDateFieldState.ts @@ -11,6 +11,7 @@ import type {DateFieldSectionWithoutPosition} from '../types'; import { addSegment, adjustDateToFormat, + connectEditableSections, getEditableSections, getFormatInfo, parseDateFromString, @@ -214,20 +215,24 @@ function useSectionsState( placeholder: DateTime, ) { const [state, setState] = React.useState(() => { + const editableSections = getEditableSections(sections, value, placeholder); + connectEditableSections(editableSections); 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); + connectEditableSections(editableSections); setState({ value, sections, placeholder, - editableSections: getEditableSections(sections, value, placeholder), + editableSections, }); } diff --git a/src/components/DateField/types.ts b/src/components/DateField/types.ts index d918e3d..003e9c4 100644 --- a/src/components/DateField/types.ts +++ b/src/components/DateField/types.ts @@ -59,16 +59,6 @@ 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 */ @@ -85,13 +75,7 @@ export interface DateFieldSection { export type DateFieldSectionWithoutPosition = Omit< TSection, - | 'start' - | 'end' - | 'value' - | 'textValue' - | 'modified' - | 'previousEditableSection' - | 'nextEditableSection' + 'start' | 'end' | 'value' | 'textValue' | 'previousEditableSection' | 'nextEditableSection' >; export type AvailableSections = Partial>; diff --git a/src/components/DateField/utils.ts b/src/components/DateField/utils.ts index 1c7fa76..52a68e0 100644 --- a/src/components/DateField/utils.ts +++ b/src/components/DateField/utils.ts @@ -667,28 +667,28 @@ export function getEditableSections( value: IncompleteDate, placeholder: DateTime, ) { - let position = 1; - const newSections: DateFieldSection[] = []; + return sections.map((section) => toEditableSection(section, value, placeholder)); +} + +export function connectEditableSections(sections: DateFieldSection[]) { let previousEditableSection = -1; + let position = 1; for (let i = 0; i < sections.length; i++) { const section = sections[i]; if (!section) { continue; } - const newSection = toEditableSection( - section, - value, - placeholder, - position, - previousEditableSection, - ); + section.start = position; + position += section.textValue.length; + section.end = position; - newSections.push(newSection); + section.previousEditableSection = previousEditableSection; + section.nextEditableSection = previousEditableSection; if (isEditableSectionType(section.type)) { for (let j = Math.max(0, previousEditableSection); j <= i; j++) { - const prevSection = newSections[j]; + const prevSection = sections[j]; if (prevSection) { prevSection.nextEditableSection = i; if (prevSection.previousEditableSection === -1) { @@ -698,11 +698,7 @@ export function getEditableSections( } previousEditableSection = i; } - - position += newSection.textValue.length; } - - return newSections; } export function isEditableSectionType( @@ -715,8 +711,6 @@ export function toEditableSection( section: DateFieldSectionWithoutPosition, value: IncompleteDate, placeholder: DateTime, - position: number, - previousEditableSection: number, ): DateFieldSection { let renderedValue = section.placeholder; let val = isEditableSectionType(section.type) ? value[section.type] : null; @@ -747,17 +741,14 @@ 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, + start: -1, + end: -1, + previousEditableSection: -1, + nextEditableSection: -1, ...getSectionLimits(section, value, placeholder), }; diff --git a/src/components/RangeDateField/utils/getRangeEditableSections.ts b/src/components/RangeDateField/utils/getRangeEditableSections.ts index ea1387d..c270b61 100644 --- a/src/components/RangeDateField/utils/getRangeEditableSections.ts +++ b/src/components/RangeDateField/utils/getRangeEditableSections.ts @@ -2,7 +2,11 @@ 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 { + connectEditableSections, + getEditableSections, + toEditableSection, +} from '../../DateField/utils'; import type {RangeValue} from '../../types'; export function getRangeEditableSections( @@ -14,11 +18,6 @@ export function getRangeEditableSections( 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', @@ -29,37 +28,10 @@ export function getRangeEditableSections( }, 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; - } + const editableSections = [...start, delimiterSection, ...end]; + connectEditableSections(editableSections); - return [...start, delimiterSection, ...end]; + return editableSections; } From 88c2b31352f397a23bef56dddc0c7fe8231440cb Mon Sep 17 00:00:00 2001 From: Valerii Sidorenko Date: Sun, 8 Feb 2026 04:44:31 +0100 Subject: [PATCH 3/5] refactor: clear active section --- .../DateField/hooks/useBaseDateFieldState.ts | 17 +++++------------ .../DateField/hooks/useDateFieldProps.ts | 2 +- .../DateField/hooks/useDateFieldState.ts | 15 +++++++-------- .../hooks/useRangeDateFieldState.test.ts | 2 +- .../hooks/useRangeDateFieldState.ts | 19 ++++++++++--------- 5 files changed, 24 insertions(+), 31 deletions(-) diff --git a/src/components/DateField/hooks/useBaseDateFieldState.ts b/src/components/DateField/hooks/useBaseDateFieldState.ts index 94c912d..d3c185c 100644 --- a/src/components/DateField/hooks/useBaseDateFieldState.ts +++ b/src/components/DateField/hooks/useBaseDateFieldState.ts @@ -26,8 +26,7 @@ export type BaseDateFieldStateOptions = { 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; }; @@ -88,7 +87,7 @@ export type DateFieldState = { incrementToMax: () => void; decrementToMin: () => void; /** Clears the value of the currently selected segment, reverting it to the placeholder. */ - clearSection: () => void; + clearActiveSection: () => void; /** Clears all segments, reverting them to the placeholder. */ clearAll: () => void; /** Handles input key in the currently selected segment */ @@ -110,8 +109,7 @@ export function useBaseDateFieldState( setValue, adjustSection, setSection, - getSectionValue, - setSectionValue, + clearSection, setValueFromString, confirmPlaceholder, } = props; @@ -267,7 +265,7 @@ export function useBaseDateFieldState( } } }, - clearSection() { + clearActiveSection() { if (this.readOnly || this.disabled) { return; } @@ -283,12 +281,7 @@ export function useBaseDateFieldState( 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) { diff --git a/src/components/DateField/hooks/useDateFieldProps.ts b/src/components/DateField/hooks/useDateFieldProps.ts index f22c256..9c51b49 100644 --- a/src/components/DateField/hooks/useDateFieldProps.ts +++ b/src/components/DateField/hooks/useDateFieldProps.ts @@ -177,7 +177,7 @@ export function useDateFieldProps( state.decrementPage(); } else if (e.key === 'Backspace' || e.key === 'Delete') { e.preventDefault(); - state.clearSection(); + state.clearActiveSection(); } else if (e.key === 'a' && (e['ctrlKey'] || e['metaKey'])) { e.preventDefault(); setSelectedSections('all'); diff --git a/src/components/DateField/hooks/useDateFieldState.ts b/src/components/DateField/hooks/useDateFieldState.ts index 355baba..3b0fbc2 100644 --- a/src/components/DateField/hooks/useDateFieldState.ts +++ b/src/components/DateField/hooks/useDateFieldState.ts @@ -14,6 +14,7 @@ import { connectEditableSections, getEditableSections, getFormatInfo, + isEditableSectionType, parseDateFromString, setSegment, useFormatSections, @@ -150,12 +151,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) { @@ -202,8 +202,7 @@ export function useDateFieldState(props: DateFieldStateOptions): DateFieldState setValue, adjustSection, setSection, - getSectionValue, - setSectionValue, + clearSection, setValueFromString, confirmPlaceholder, }); diff --git a/src/components/RangeDateField/hooks/useRangeDateFieldState.test.ts b/src/components/RangeDateField/hooks/useRangeDateFieldState.test.ts index 8180d67..75b40d0 100644 --- a/src/components/RangeDateField/hooks/useRangeDateFieldState.test.ts +++ b/src/components/RangeDateField/hooks/useRangeDateFieldState.test.ts @@ -131,7 +131,7 @@ test('can clear the section or the entire range', async () => { const position = result.current.sections.filter((e) => isEditableSectionType(e.type))[4]?.end; act(() => result.current.focusSectionInPosition(position)); - act(() => result.current.clearSection()); + act(() => result.current.clearActiveSection()); expect(cleanString(result.current.text)).toBe('20.01.2024 — 24.MM.2024'); diff --git a/src/components/RangeDateField/hooks/useRangeDateFieldState.ts b/src/components/RangeDateField/hooks/useRangeDateFieldState.ts index 419fd87..4d8efda 100644 --- a/src/components/RangeDateField/hooks/useRangeDateFieldState.ts +++ b/src/components/RangeDateField/hooks/useRangeDateFieldState.ts @@ -11,6 +11,7 @@ import { addSegment, adjustDateToFormat, getFormatInfo, + isEditableSectionType, parseDateFromString, setSegment, useFormatSections, @@ -176,14 +177,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) { @@ -253,8 +255,7 @@ export function useRangeDateFieldState(props: RangeDateFieldStateOptions): Range setValue, adjustSection, setSection, - getSectionValue, - setSectionValue, + clearSection, setValueFromString, confirmPlaceholder, }); From 8662c053bb1c13e2a95b28036329ce41edbe9985 Mon Sep 17 00:00:00 2001 From: Valerii Sidorenko Date: Sun, 8 Feb 2026 22:43:49 +0100 Subject: [PATCH 4/5] refactor: useBaseDateFieldState - move focus management to separate hook - move onInput logic to useDateFieldProps --- .../__tests__/useDateFieldState.test.ts | 89 ----- .../DateField/hooks/useBaseDateFieldState.ts | 305 ++---------------- .../DateField/hooks/useDateFieldProps.ts | 229 +++++++++++-- .../DateField/hooks/useFocusManager.ts | 91 ++++++ src/components/DateField/utils.ts | 2 +- .../__tests__/RangeDateField.test.tsx | 121 +++++++ .../hooks/useRangeDateFieldState.test.ts | 141 -------- src/components/utils/constants.ts | 9 + 8 files changed, 453 insertions(+), 534 deletions(-) delete mode 100644 src/components/DateField/__tests__/useDateFieldState.test.ts create mode 100644 src/components/DateField/hooks/useFocusManager.ts delete mode 100644 src/components/RangeDateField/hooks/useRangeDateFieldState.test.ts diff --git a/src/components/DateField/__tests__/useDateFieldState.test.ts b/src/components/DateField/__tests__/useDateFieldState.test.ts deleted file mode 100644 index fc44dc5..0000000 --- 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 d3c185c..8ba3d1a 100644 --- a/src/components/DateField/hooks/useBaseDateFieldState.ts +++ b/src/components/DateField/hooks/useBaseDateFieldState.ts @@ -1,22 +1,13 @@ -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, formatSections} from '../utils'; -export type BaseDateFieldStateOptions = { +interface BaseDateFieldStateOptions { value: T | null; displayValue: T; - placeholderValue?: DateTime; - timeZone: string; validationState?: ValidationState; editableSections: DateFieldSection[]; formatInfo: FormatInfo; @@ -29,9 +20,9 @@ export type BaseDateFieldStateOptions = { 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. */ @@ -40,7 +31,7 @@ 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; @@ -48,53 +39,39 @@ export type DateFieldState = { 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. */ - clearActiveSection: () => 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, @@ -114,32 +91,6 @@ export function useBaseDateFieldState( confirmPlaceholder, } = props; - 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: editableSections.length - 1, - }; - } - - if (typeof selectedSections === 'number') { - return {startIndex: selectedSections, endIndex: selectedSections}; - } - - return selectedSections; - }, [selectedSections, editableSections]); - - const enteredKeys = React.useRef(''); - return { value, isEmpty, @@ -151,100 +102,48 @@ export function useBaseDateFieldState( 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) { - this.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') { @@ -252,12 +151,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') { @@ -265,18 +162,11 @@ export function useBaseDateFieldState( } } }, - clearActiveSection() { + 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; } @@ -288,141 +178,8 @@ export function useBaseDateFieldState( 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 9c51b49..f497fca 100644 --- a/src/components/DateField/hooks/useDateFieldProps.ts +++ b/src/components/DateField/hooks/useDateFieldProps.ts @@ -14,9 +14,11 @@ import type { StyleProps, TextInputExtendProps, } from '../../types'; +import {CtrlCmd} from '../../utils/constants.js'; import {cleanString} from '../utils'; import type {DateFieldState} from './useBaseDateFieldState'; +import {useFocusManager} from './useFocusManager'; export interface DateFieldProps extends @@ -38,8 +40,13 @@ export function useDateFieldProps( const [, setInnerState] = React.useState({}); + const enteredKeys = React.useRef(''); + + const focusManager = useFocusManager({sections: state.sections}); + function setSelectedSections(section: 'all' | number) { - state.setSelectedSections(section); + enteredKeys.current = ''; + focusManager.setSelectedSections(section); setInnerState({}); } @@ -49,7 +56,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 +66,8 @@ export function useDateFieldProps( return; } - const firstSelectedSection = state.sections[state.selectedSectionIndexes.startIndex]; - const lastSelectedSection = state.sections[state.selectedSectionIndexes.endIndex]; + const firstSelectedSection = state.sections[focusManager.selectedSectionIndexes.startIndex]; + const lastSelectedSection = state.sections[focusManager.selectedSectionIndexes.endIndex]; if (firstSelectedSection && lastSelectedSection) { const selectionStart = firstSelectedSection.start; const selectionEnd = lastSelectedSection.end; @@ -75,22 +82,167 @@ export function useDateFieldProps( }); function syncSelectionFromDOM() { - state.focusSectionInPosition(inputRef.current?.selectionStart ?? 0); + enteredKeys.current = ''; + focusManager.focusSectionInPosition(inputRef.current?.selectionStart ?? 0); 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 = 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) { + 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: { @@ -120,7 +272,7 @@ export function useDateFieldProps( onFocus(e) { props.onFocus?.(e); - if (state.selectedSectionIndexes !== null) { + if (focusManager.selectedSectionIndexes !== null) { return; } const input = e.target; @@ -130,7 +282,7 @@ export function useDateFieldProps( return; } if (isAutofocus) { - state.focusSectionInPosition(0); + focusManager.focusSectionInPosition(0); } else if ( // avoid selecting all sections when focusing empty field without value input.value.length && @@ -153,32 +305,41 @@ 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.clearActiveSection(); - } else if (e.key === 'a' && (e['ctrlKey'] || e['metaKey'])) { + enteredKeys.current = ''; + backspace(); + } else if (e.key === 'a' && e[CtrlCmd]) { e.preventDefault(); setSelectedSections('all'); } @@ -200,9 +361,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 +382,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 +400,7 @@ export function useDateFieldProps( (activeSection.contentType === 'letter' && lettersOnly)), ); if (isValidValue) { - state.onInput(pastedValue); + onInput(pastedValue); return; } if (digitsOnly || lettersOnly) { diff --git a/src/components/DateField/hooks/useFocusManager.ts b/src/components/DateField/hooks/useFocusManager.ts new file mode 100644 index 0000000..11c342c --- /dev/null +++ b/src/components/DateField/hooks/useFocusManager.ts @@ -0,0 +1,91 @@ +import React from 'react'; + +import type {DateFieldSection} from '../types'; +import {getCurrentEditableSectionIndex} from '../utils'; + +interface FocusManagerOptions { + sections: DateFieldSection[]; +} + +interface FocusManager { + /** */ + activeSectionIndex: number; + /** Selected sections */ + selectedSectionIndexes: {startIndex: number; endIndex: number} | null; + /** 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; +} + +export function useFocusManager({sections}: FocusManagerOptions): FocusManager { + 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: sections.length - 1, + }; + } + + if (typeof selectedSections === 'number') { + return {startIndex: selectedSections, endIndex: selectedSections}; + } + + return selectedSections; + }, [selectedSections, sections]); + + const activeSectionIndex = getCurrentEditableSectionIndex(sections, selectedSections); + + return { + activeSectionIndex, + selectedSectionIndexes, + setSelectedSections, + focusSectionInPosition(position) { + const nextSectionIndex = sections.findIndex((s) => s.end >= position); + this.setSelectedSections(getCurrentEditableSectionIndex(sections, nextSectionIndex)); + }, + focusNextSection() { + const currentIndex = selectedSections === 'all' ? 0 : selectedSections; + const newIndex = sections[currentIndex]?.nextEditableSection ?? -1; + if (newIndex !== -1) { + this.setSelectedSections(newIndex); + } + }, + focusPreviousSection() { + const currentIndex = selectedSections === 'all' ? 0 : selectedSections; + const newIndex = sections[currentIndex]?.previousEditableSection ?? -1; + if (newIndex !== -1) { + this.setSelectedSections(newIndex); + } + }, + focusFirstSection() { + const newIndex = sections[0]?.previousEditableSection ?? -1; + if (newIndex !== -1) { + this.setSelectedSections(newIndex); + } + }, + focusLastSection() { + const newIndex = sections[sections.length - 1]?.nextEditableSection ?? -1; + if (newIndex !== -1) { + this.setSelectedSections(newIndex); + } + }, + }; +} diff --git a/src/components/DateField/utils.ts b/src/components/DateField/utils.ts index 52a68e0..9f517d2 100644 --- a/src/components/DateField/utils.ts +++ b/src/components/DateField/utils.ts @@ -765,7 +765,7 @@ export function getCurrentEditableSectionIndex( if (section && !isEditableSectionType(section.type)) { return section.nextEditableSection; } - return section ? currentIndex : -1; + return isEditableSectionType(section?.type) ? currentIndex : -1; } export function formatSections(sections: DateFieldSection[]): string { diff --git a/src/components/RangeDateField/__tests__/RangeDateField.test.tsx b/src/components/RangeDateField/__tests__/RangeDateField.test.tsx index 01af393..26d827e 100644 --- a/src/components/RangeDateField/__tests__/RangeDateField.test.tsx +++ b/src/components/RangeDateField/__tests__/RangeDateField.test.tsx @@ -4,9 +4,130 @@ 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 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/hooks/useRangeDateFieldState.test.ts b/src/components/RangeDateField/hooks/useRangeDateFieldState.test.ts deleted file mode 100644 index 75b40d0..0000000 --- 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.clearActiveSection()); - - 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/utils/constants.ts b/src/components/utils/constants.ts index 77e47bb..3dce956 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'; From 7aa6f7487cb739397fa6dc74362190d98fc6fb01 Mon Sep 17 00:00:00 2001 From: Valerii Sidorenko Date: Tue, 10 Feb 2026 11:10:19 +0100 Subject: [PATCH 5/5] refactor: move things around - move useRangeDateFieldState to DateField hooks - calculate connections between sections in useFocusManager - generate input value and section positions in useDateFieldProps --- src/components/DateField/DateField.tsx | 5 +- .../DateField/hooks/useBaseDateFieldState.ts | 5 +- .../DateField/hooks/useDateFieldProps.ts | 62 +++++---- .../DateField/hooks/useDateFieldState.ts | 13 +- .../DateField/hooks/useFocusManager.ts | 121 ++++++++++++++---- .../hooks/useRangeDateFieldState.ts | 56 ++++++-- src/components/DateField/index.ts | 4 +- src/components/DateField/types.ts | 20 +-- src/components/DateField/utils.ts | 82 ++---------- .../RangeDateField/RangeDateField.tsx | 3 +- .../__tests__/RangeDateField.test.tsx | 30 +++++ .../__tests__/parseDateFromString.test.ts | 77 ----------- .../RangeDateField/__tests__/utils.test.ts | 54 -------- src/components/RangeDateField/index.ts | 2 - .../utils/createPlaceholderRangeValue.ts | 7 - .../utils/getRangeEditableSections.ts | 37 ------ src/components/RangeDateField/utils/index.ts | 3 - .../RangeDateField/utils/isValidRange.ts | 7 - .../hooks/useRangeDatePickerState.ts | 7 +- src/components/utils/dates.ts | 7 +- 20 files changed, 240 insertions(+), 362 deletions(-) rename src/components/{RangeDateField => DateField}/hooks/useRangeDateFieldState.ts (89%) delete mode 100644 src/components/RangeDateField/__tests__/parseDateFromString.test.ts delete mode 100644 src/components/RangeDateField/__tests__/utils.test.ts delete mode 100644 src/components/RangeDateField/utils/createPlaceholderRangeValue.ts delete mode 100644 src/components/RangeDateField/utils/getRangeEditableSections.ts delete mode 100644 src/components/RangeDateField/utils/index.ts delete mode 100644 src/components/RangeDateField/utils/isValidRange.ts diff --git a/src/components/DateField/DateField.tsx b/src/components/DateField/DateField.tsx index 13637b1..c9488fb 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/hooks/useBaseDateFieldState.ts b/src/components/DateField/hooks/useBaseDateFieldState.ts index 8ba3d1a..d5b135e 100644 --- a/src/components/DateField/hooks/useBaseDateFieldState.ts +++ b/src/components/DateField/hooks/useBaseDateFieldState.ts @@ -3,7 +3,7 @@ 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} from '../utils'; +import {PAGE_STEP} from '../utils'; interface BaseDateFieldStateOptions { value: T | null; @@ -33,8 +33,6 @@ export interface DateFieldState { setValue: (value: T | null) => void; /** 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. */ @@ -97,7 +95,6 @@ export function useBaseDateFieldState( displayValue, setValue, confirmPlaceholder, - text: formatSections(editableSections), readOnly: props.readOnly, disabled: props.disabled, sections: editableSections, diff --git a/src/components/DateField/hooks/useDateFieldProps.ts b/src/components/DateField/hooks/useDateFieldProps.ts index f497fca..899a059 100644 --- a/src/components/DateField/hooks/useDateFieldProps.ts +++ b/src/components/DateField/hooks/useDateFieldProps.ts @@ -15,6 +15,7 @@ import type { TextInputExtendProps, } from '../../types'; import {CtrlCmd} from '../../utils/constants.js'; +import type {DateFieldSection} from '../types'; import {cleanString} from '../utils'; import type {DateFieldState} from './useBaseDateFieldState'; @@ -44,11 +45,7 @@ export function useDateFieldProps( const focusManager = useFocusManager({sections: state.sections}); - function setSelectedSections(section: 'all' | number) { - enteredKeys.current = ''; - focusManager.setSelectedSections(section); - setInnerState({}); - } + const {text, positions} = React.useMemo(() => prepareState(state.sections), [state.sections]); React.useLayoutEffect(() => { const inputElement = inputRef.current; @@ -66,8 +63,8 @@ export function useDateFieldProps( return; } - const firstSelectedSection = state.sections[focusManager.selectedSectionIndexes.startIndex]; - const lastSelectedSection = state.sections[focusManager.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; @@ -83,7 +80,9 @@ export function useDateFieldProps( function syncSelectionFromDOM() { enteredKeys.current = ''; - focusManager.focusSectionInPosition(inputRef.current?.selectionStart ?? 0); + const position = inputRef.current?.selectionStart ?? 0; + const index = positions.findIndex((s) => s.end >= position); + focusManager.focusSection(index === -1 ? 0 : index); setInnerState({}); } @@ -111,7 +110,7 @@ export function useDateFieldProps( } const section = state.sections[sectionIndex]; - const isLastSection = section.nextEditableSection === sectionIndex; + const isLastSection = focusManager.isLastSection(sectionIndex); let newValue = enteredKeys.current + key; const onInputNumber = (numberValue: number) => { @@ -246,7 +245,7 @@ export function useDateFieldProps( return { inputProps: { - value: state.text, + value: text, view: props.view, size: props.size, disabled: state.disabled, @@ -273,31 +272,21 @@ export function useDateFieldProps( props.onFocus?.(e); if (focusManager.selectedSectionIndexes !== null) { + setInnerState({}); return; } const input = e.target; - const isAutofocus = !inputRef.current; setTimeout(() => { if (!input || input !== inputRef.current) { return; } - if (isAutofocus) { - focusManager.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) { @@ -341,7 +330,8 @@ export function useDateFieldProps( backspace(); } else if (e.key === 'a' && e[CtrlCmd]) { e.preventDefault(); - setSelectedSections('all'); + enteredKeys.current = ''; + focusManager.focusSection('all'); } }, onKeyUp: props.onKeyUp, @@ -414,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 3b0fbc2..bd637f3 100644 --- a/src/components/DateField/hooks/useDateFieldState.ts +++ b/src/components/DateField/hooks/useDateFieldState.ts @@ -7,11 +7,10 @@ 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, - connectEditableSections, getEditableSections, getFormatInfo, isEditableSectionType, @@ -191,8 +190,6 @@ export function useDateFieldState(props: DateFieldStateOptions): DateFieldState return useBaseDateFieldState({ value, displayValue: dateValue, - placeholderValue: props.placeholderValue, - timeZone, validationState, editableSections: sectionsState.editableSections, formatInfo, @@ -208,14 +205,9 @@ export function useDateFieldState(props: DateFieldStateOptions): DateFieldState }); } -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); - connectEditableSections(editableSections); return { value, sections, @@ -226,7 +218,6 @@ function useSectionsState( if (sections !== state.sections || placeholder !== state.placeholder || value !== state.value) { const editableSections = getEditableSections(sections, value, placeholder); - connectEditableSections(editableSections); setState({ value, sections, diff --git a/src/components/DateField/hooks/useFocusManager.ts b/src/components/DateField/hooks/useFocusManager.ts index 11c342c..8de6ec6 100644 --- a/src/components/DateField/hooks/useFocusManager.ts +++ b/src/components/DateField/hooks/useFocusManager.ts @@ -1,34 +1,38 @@ import React from 'react'; -import type {DateFieldSection} from '../types'; -import {getCurrentEditableSectionIndex} from '../utils'; +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; - /** 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 */ + /** 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 segment if present */ + /** Focuses the previous section if present */ focusPreviousSection: () => void; - /** Focuses the first segment */ + /** Focuses the first section */ focusFirstSection: () => void; - /** Focuses the last segment */ + /** 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; @@ -40,7 +44,7 @@ export function useFocusManager({sections}: FocusManagerOptions): FocusManager { if (selectedSections === 'all') { return { startIndex: 0, - endIndex: sections.length - 1, + endIndex: connections.length - 1, }; } @@ -49,43 +53,108 @@ export function useFocusManager({sections}: FocusManagerOptions): FocusManager { } return selectedSections; - }, [selectedSections, sections]); + }, [selectedSections, connections]); - const activeSectionIndex = getCurrentEditableSectionIndex(sections, selectedSections); + const activeSectionIndex = getCurrentEditableSectionIndex(connections, selectedSections); return { activeSectionIndex, selectedSectionIndexes, - setSelectedSections, - focusSectionInPosition(position) { - const nextSectionIndex = sections.findIndex((s) => s.end >= position); - this.setSelectedSections(getCurrentEditableSectionIndex(sections, nextSectionIndex)); + 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 = sections[currentIndex]?.nextEditableSection ?? -1; + const newIndex = connections[currentIndex]?.nextEditableSection ?? -1; if (newIndex !== -1) { - this.setSelectedSections(newIndex); + setSelectedSections(newIndex); } }, focusPreviousSection() { const currentIndex = selectedSections === 'all' ? 0 : selectedSections; - const newIndex = sections[currentIndex]?.previousEditableSection ?? -1; + const newIndex = connections[currentIndex]?.previousEditableSection ?? -1; if (newIndex !== -1) { - this.setSelectedSections(newIndex); + setSelectedSections(newIndex); } }, focusFirstSection() { - const newIndex = sections[0]?.previousEditableSection ?? -1; + const newIndex = connections[0]?.previousEditableSection ?? -1; if (newIndex !== -1) { - this.setSelectedSections(newIndex); + setSelectedSections(newIndex); } }, focusLastSection() { - const newIndex = sections[sections.length - 1]?.nextEditableSection ?? -1; + const newIndex = connections[sections.length - 1]?.nextEditableSection ?? -1; if (newIndex !== -1) { - this.setSelectedSections(newIndex); + 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 89% rename from src/components/RangeDateField/hooks/useRangeDateFieldState.ts rename to src/components/DateField/hooks/useRangeDateFieldState.ts index 4d8efda..704be09 100644 --- a/src/components/RangeDateField/hooks/useRangeDateFieldState.ts +++ b/src/components/DateField/hooks/useRangeDateFieldState.ts @@ -3,24 +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; @@ -243,8 +245,6 @@ export function useRangeDateFieldState(props: RangeDateFieldStateOptions): Range return useBaseDateFieldState, RangeValue>({ value, displayValue: rangeValue, - placeholderValue: props.placeholderValue, - timeZone, validationState, editableSections: sectionsState.editableSections, formatInfo, @@ -262,7 +262,7 @@ export function useRangeDateFieldState(props: RangeDateFieldStateOptions): Range } function useSectionsState( - sections: DateFieldSectionWithoutPosition[], + sections: FormatSection[], value: RangeValue, placeholder: RangeValue, delimiter: string, @@ -297,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 e941dcf..208cfc9 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 003e9c4..78de41d 100644 --- a/src/components/DateField/types.ts +++ b/src/components/DateField/types.ts @@ -59,24 +59,12 @@ export interface DateFieldSection { * For example, the value `1` should be rendered as "01" instead of "1". */ hasLeadingZeros: 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' | '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 9f517d2..96e147d 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,44 +659,13 @@ export function cleanString(dirtyString: string) { } export function getEditableSections( - sections: DateFieldSectionWithoutPosition[], + sections: FormatSection[], value: IncompleteDate, placeholder: DateTime, ) { return sections.map((section) => toEditableSection(section, value, placeholder)); } -export function connectEditableSections(sections: DateFieldSection[]) { - let previousEditableSection = -1; - let position = 1; - for (let i = 0; i < sections.length; i++) { - const section = sections[i]; - if (!section) { - continue; - } - - section.start = position; - position += section.textValue.length; - section.end = position; - - section.previousEditableSection = previousEditableSection; - section.nextEditableSection = previousEditableSection; - - if (isEditableSectionType(section.type)) { - for (let j = Math.max(0, previousEditableSection); j <= i; j++) { - const prevSection = sections[j]; - if (prevSection) { - prevSection.nextEditableSection = i; - if (prevSection.previousEditableSection === -1) { - prevSection.previousEditableSection = i; - } - } - } - previousEditableSection = i; - } - } -} - export function isEditableSectionType( type: DateFieldSectionType, ): type is keyof typeof EDITABLE_SEGMENTS { @@ -708,7 +673,7 @@ export function isEditableSectionType( } export function toEditableSection( - section: DateFieldSectionWithoutPosition, + section: FormatSection, value: IncompleteDate, placeholder: DateTime, ): DateFieldSection { @@ -738,41 +703,16 @@ export function toEditableSection( } } - // use bidirectional context to allow the browser autodetect text direction - renderedValue = '\u2068' + renderedValue + '\u2069'; - const newSection = { ...section, value: getSectionValue(section, value), textValue: renderedValue, - start: -1, - end: -1, - previousEditableSection: -1, - nextEditableSection: -1, ...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 isEditableSectionType(section?.type) ? 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()) { @@ -824,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 0e80022..542d962 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 26d827e..008f2cf 100644 --- a/src/components/RangeDateField/__tests__/RangeDateField.test.tsx +++ b/src/components/RangeDateField/__tests__/RangeDateField.test.tsx @@ -101,6 +101,36 @@ describe('RangeDateField', () => { 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( ({ - ...(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 79bbd5c..0000000 --- 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/index.ts b/src/components/RangeDateField/index.ts index a5cccac..476bad4 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 2f1c1fa..0000000 --- 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 c270b61..0000000 --- a/src/components/RangeDateField/utils/getRangeEditableSections.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type {DateTime} from '@gravity-ui/date-utils'; - -import type {IncompleteDate} from '../../DateField/IncompleteDate.js'; -import type {DateFieldSectionWithoutPosition} from '../../DateField/types'; -import { - connectEditableSections, - getEditableSections, - 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 delimiterSection = toEditableSection( - { - type: 'literal', - contentType: 'letter', - format: delimiter, - placeholder: delimiter, - hasLeadingZeros: false, - }, - value.start, - placeholder.start, - ); - - const editableSections = [...start, delimiterSection, ...end]; - connectEditableSections(editableSections); - - return editableSections; -} diff --git a/src/components/RangeDateField/utils/index.ts b/src/components/RangeDateField/utils/index.ts deleted file mode 100644 index 3f3235d..0000000 --- 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 ec965da..0000000 --- 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 24b9f66..c646c00 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/dates.ts b/src/components/utils/dates.ts index ad9c726..fe9c380 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,