diff --git a/src/components/DateField/DateField.tsx b/src/components/DateField/DateField.tsx index 36ac105a..13637b1d 100644 --- a/src/components/DateField/DateField.tsx +++ b/src/components/DateField/DateField.tsx @@ -42,7 +42,7 @@ export function DateField({className, ...props}: DateFieldProps) { value={state.value} toStringValue={(value) => value?.toISOString() ?? ''} onReset={(value) => { - state.setDate(value); + state.setValue(value); }} disabled={state.disabled} form={props.form} diff --git a/src/components/DateField/IncompleteDate.ts b/src/components/DateField/IncompleteDate.ts new file mode 100644 index 00000000..213ec4f5 --- /dev/null +++ b/src/components/DateField/IncompleteDate.ts @@ -0,0 +1,135 @@ +import type {DateTime} from '@gravity-ui/date-utils'; + +import type {AvailableSections} from './types'; +import {getDurationUnitFromSectionType} from './utils'; + +const dateFields = [ + 'year', + 'quarter', + 'month', + 'day', + 'weekday', + 'hour', + 'minute', + 'second', + 'dayPeriod', +] as const; + +type Field = (typeof dateFields)[number]; + +export class IncompleteDate { + year: number | null; + month: number | null; + day: number | null; + weekday: number | null; + dayPeriod: number | null; + hour: number | null; + minute: number | null; + second: number | null; + + constructor(date?: DateTime | null) { + this.year = date?.year() ?? null; + this.month = date ? date.month() + 1 : null; + this.day = date?.date() ?? null; + this.weekday = date?.day() ?? null; + this.hour = date?.hour() ?? null; + this.minute = date?.minute() ?? null; + this.second = date?.second() ?? null; + if (date) { + this.dayPeriod = date.hour() >= 12 ? 1 : 0; + } else { + this.dayPeriod = null; + } + } + + get quarter() { + return this.month === null ? null : Math.ceil(this.month / 3); + } + + set quarter(v: number | null) { + this.month = + v === null + ? null + : (v - 1) * 3 + (this.month === null ? 1 : ((this.month - 1) % 3) + 13); + } + + copy() { + const copy = new IncompleteDate(); + for (const field of dateFields) { + copy[field] = this[field]; + } + return copy; + } + + isComplete(availableUnits: AvailableSections): boolean { + return dateFields.every((field) => !availableUnits[field] || this[field] !== null); + } + + validate(date: DateTime, availableUnits: AvailableSections): boolean { + return dateFields.every((field) => { + if (!availableUnits[field]) { + return true; + } + + if (field === 'dayPeriod') { + return this.dayPeriod === (date.hour() >= 12 ? 1 : 0); + } + + if (field === 'month') { + return date.month() + 1 === this.month; + } + + return date[getDurationUnitFromSectionType(field)]() === this[field]; + }); + } + + isCleared(availableUnits: AvailableSections): boolean { + return dateFields.every((field) => !availableUnits[field] || this[field] === null); + } + + set(field: Field, value: number): IncompleteDate { + const copy = this.copy(); + copy[field] = value; + if (field === 'hour') { + copy.dayPeriod = (copy.hour ?? 0) >= 12 ? 1 : 0; + } + + return copy; + } + + clear(field: Field): IncompleteDate { + const copy = this.copy(); + copy[field] = null; + return copy; + } + + toDateTime( + baseValue: DateTime, + {setDate, setTime}: {setDate: boolean; setTime: boolean}, + ): DateTime { + let nextValue = baseValue; + if (setDate) { + nextValue = nextValue + .set({ + year: this.year ?? baseValue.year(), + month: 0, // set January to not overflow day value + date: this.day ?? baseValue.date(), + }) + .set({month: this.month === null ? baseValue.month() : this.month - 1}); + if (this.day === null && this.weekday !== null) { + nextValue = nextValue.set({day: this.weekday}); + } + } + if (setTime) { + nextValue = nextValue + .set({ + hour: this.hour ?? baseValue.hour(), + minute: this.minute ?? baseValue.minute(), + second: this.second ?? baseValue.second(), + }) + .timeZone(nextValue.timeZone()); + } + + return nextValue; + } +} diff --git a/src/components/DateField/__tests__/DateField.test.tsx b/src/components/DateField/__tests__/DateField.test.tsx new file mode 100644 index 00000000..39bde00e --- /dev/null +++ b/src/components/DateField/__tests__/DateField.test.tsx @@ -0,0 +1,102 @@ +import {dateTime} from '@gravity-ui/date-utils'; +import {describe, expect, it, vi} from 'vitest'; +import {userEvent} from 'vitest/browser'; + +import {render} from '#test-utils/utils'; + +import {DateField} from '../DateField'; +import {cleanString} from '../utils'; + +describe('DateField', () => { + describe('format rendering', () => { + it('renders two-digit year correctly for YY format', async () => { + const value = dateTime({input: '2024-01-15T00:00:00'}); + const screen = await render(); + + const input = screen.getByRole('textbox').element() as HTMLInputElement; + expect(cleanString(input.value)).toBe(value.format('YY')); + }); + + it('renders ordinal day token correctly for Do format', async () => { + const value = dateTime({input: '2024-04-01T00:00:00'}); + const screen = await render(); + + const input = screen.getByRole('textbox').element() as HTMLInputElement; + expect(cleanString(input.value)).toBe(value.format('Do MMMM YYYY')); + }); + + it('renders ordinal quarter token correctly for Qo format', async () => { + const value = dateTime({input: '2024-05-01T00:00:00'}); + const screen = await render(); + + const input = screen.getByRole('textbox').element() as HTMLInputElement; + expect(cleanString(input.value)).toBe(value.format('Qo YYYY')); + }); + + it('keeps entered textual month during intermediate invalid state', async () => { + const timeZone = 'Europe/Amsterdam'; + const screen = await render( + , + ); + + await userEvent.keyboard('{Tab}'); + await userEvent.keyboard('31042024'); + + const input = screen.getByRole('textbox').element() as HTMLInputElement; + expect(cleanString(input.value)).toBe('31 April 2024'); + }); + }); + + describe('invalid date', () => { + it('should allow to enter 31 april and constrains the date on blur', async () => { + const onUpdate = vi.fn(); + const timeZone = 'Europe/Amsterdam'; + const screen = await render( + , + ); + await userEvent.keyboard('{Tab}'); + await userEvent.keyboard('31042024'); + + const input = screen.getByRole('textbox').element() as HTMLInputElement; + + expect(onUpdate).not.toHaveBeenCalled(); + expect(cleanString(input.value)).toBe('31.04.2024'); + + await userEvent.keyboard('{Tab}'); + + expect(onUpdate).toHaveBeenCalledWith(dateTime({input: '2024-04-30', timeZone})); + expect(cleanString(input.value)).toBe('30.04.2024'); + }); + + it('should allow to enter 2am during a forward DST transition', async () => { + const onUpdate = vi.fn(); + const timeZone = 'Europe/Amsterdam'; + const screen = await render( + , + ); + await userEvent.keyboard('{Tab}'); + await userEvent.keyboard('310320240200'); + + const input = screen.getByRole('textbox').element() as HTMLInputElement; + + expect(onUpdate).not.toHaveBeenCalled(); + expect(cleanString(input.value)).toBe('31.03.2024 02:00'); + + await userEvent.keyboard('{Tab}'); + + expect(onUpdate).toHaveBeenCalledWith(dateTime({input: '2024-03-31T01:00Z', timeZone})); + expect(cleanString(input.value)).toBe('31.03.2024 03:00'); + }); + }); +}); diff --git a/src/components/DateField/__tests__/useDateFieldState.test.ts b/src/components/DateField/__tests__/useDateFieldState.test.ts new file mode 100644 index 00000000..fc44dc5a --- /dev/null +++ b/src/components/DateField/__tests__/useDateFieldState.test.ts @@ -0,0 +1,89 @@ +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 30ed7225..7bb83aba 100644 --- a/src/components/DateField/hooks/useBaseDateFieldState.ts +++ b/src/components/DateField/hooks/useBaseDateFieldState.ts @@ -3,27 +3,16 @@ import React from 'react'; import type {DateTime} from '@gravity-ui/date-utils'; import type {ValidationState} from '../../types'; -import {createPlaceholderValue} from '../../utils/dates'; -import type {DateFieldSection, DateFieldSectionType, FormatInfo} from '../types'; +import type {IncompleteDate} from '../IncompleteDate'; +import type {DateFieldSection, FormatInfo} from '../types'; import { - EDITABLE_SEGMENTS, + PAGE_STEP, formatSections, getCurrentEditableSectionIndex, - getDurationUnitFromSectionType, + isEditableSectionType, } from '../utils'; -const PAGE_STEP: Partial> = { - year: 5, - quarter: 2, - month: 2, - weekday: 3, - day: 7, - hour: 2, - minute: 15, - second: 15, -}; - -export type BaseDateFieldStateOptions = { +export type BaseDateFieldStateOptions = { value: T | null; displayValue: T; placeholderValue?: DateTime; @@ -36,17 +25,14 @@ export type BaseDateFieldStateOptions = { selectedSectionIndexes: {startIndex: number; endIndex: number} | null; selectedSections: number | 'all'; isEmpty: boolean; - flushAllValidSections: () => void; - flushValidSection: (sectionIndex: number) => void; setSelectedSections: (position: number | 'all') => void; - setValue: (value: T) => void; - setDate: (value: T | null) => void; + setValue: (value: T | V | null) => void; adjustSection: (sectionIndex: number, amount: number) => void; setSection: (sectionIndex: number, amount: number) => void; - getSectionValue: (sectionIndex: number) => DateTime; - setSectionValue: (sectionIndex: number, currentValue: DateTime) => void; - createPlaceholder: () => T; + getSectionValue: (sectionIndex: number) => IncompleteDate; + setSectionValue: (sectionIndex: number, currentValue: IncompleteDate) => void; setValueFromString: (str: string) => boolean; + confirmPlaceholder: () => void; }; export type DateFieldState = { @@ -57,9 +43,9 @@ export type DateFieldState = { /** The current used value. value or placeholderValue */ displayValue: T; /** Sets the field's value. */ - setValue: (value: T) => void; - /** Sets the date value. */ - setDate: (value: T | null) => void; + setValue: (value: T | null) => void; + /** Updates the remaining unfilled segments with the placeholder value. */ + confirmPlaceholder: () => void; /** Formatted value */ text: string; /** Whether the field is read only. */ @@ -70,16 +56,6 @@ export type DateFieldState = { sections: DateFieldSection[]; /** Some info about available sections */ formatInfo: FormatInfo; - /** - * @deprecated use formatInfo.hasDate instead. - * Whether the the format is containing date parts - */ - hasDate: boolean; - /** - * @deprecated use formatInfo.hasTime instead. - * Whether the the format is containing time parts - */ - hasTime: boolean; /** Selected sections */ selectedSectionIndexes: {startIndex: number; endIndex: number} | null; /** The current validation state of the date field, based on the `validationState`, `minValue`, and `maxValue` props. */ @@ -124,8 +100,8 @@ export type DateFieldState = { setValueFromString: (str: string) => boolean; }; -export function useBaseDateFieldState( - props: BaseDateFieldStateOptions, +export function useBaseDateFieldState( + props: BaseDateFieldStateOptions, ): DateFieldState { const { value, @@ -136,17 +112,14 @@ export function useBaseDateFieldState( selectedSectionIndexes, selectedSections, isEmpty, - flushAllValidSections, - flushValidSection, setSelectedSections, setValue, - setDate, adjustSection, setSection, getSectionValue, setSectionValue, - createPlaceholder, setValueFromString, + confirmPlaceholder, } = props; const enteredKeys = React.useRef(''); @@ -156,14 +129,12 @@ export function useBaseDateFieldState( isEmpty, displayValue, setValue, - setDate, + confirmPlaceholder, text: formatSections(editableSections), readOnly: props.readOnly, disabled: props.disabled, sections: editableSections, formatInfo, - hasDate: formatInfo.hasDate, - hasTime: formatInfo.hasTime, selectedSectionIndexes, validationState, setSelectedSections(position) { @@ -176,7 +147,9 @@ export function useBaseDateFieldState( const nextSection = this.sections[index]; if (nextSection) { this.setSelectedSections( - EDITABLE_SEGMENTS[nextSection.type] ? index : nextSection.nextEditableSection, + isEditableSectionType(nextSection.type) + ? index + : nextSection.nextEditableSection, ); } }, @@ -292,33 +265,12 @@ export function useBaseDateFieldState( return; } - flushValidSection(sectionIndex); - const section = this.sections[sectionIndex]; - const placeholder = createPlaceholderValue({ - placeholderValue: props.placeholderValue, - timeZone: props.timeZone, - }).timeZone(props.timeZone); - const displayPortion = getSectionValue(sectionIndex); - let currentValue = displayPortion; - - // Reset day period to default without changing the hour. - if (section.type === 'dayPeriod') { - const isPM = displayPortion.hour() >= 12; - const shouldBePM = placeholder.hour() >= 12; - if (isPM && !shouldBePM) { - currentValue = displayPortion.set('hour', displayPortion.hour() - 12); - } else if (!isPM && shouldBePM) { - currentValue = displayPortion.set('hour', displayPortion.hour() + 12); - } - } else { - const type = getDurationUnitFromSectionType(section.type); - currentValue = displayPortion.set(type, placeholder[type]()); + if (isEditableSectionType(section.type)) { + setSectionValue(sectionIndex, displayPortion.clear(section.type)); } - - setSectionValue(sectionIndex, currentValue); }, clearAll() { if (this.readOnly || this.disabled) { @@ -326,14 +278,7 @@ export function useBaseDateFieldState( } enteredKeys.current = ''; - flushAllValidSections(); - if (value !== null) { - setDate(null); - } - - const date = createPlaceholder(); - - setValue(date); + setValue(null); }, onInput(key: string) { if (this.readOnly || this.disabled) { @@ -350,7 +295,7 @@ export function useBaseDateFieldState( let newValue = enteredKeys.current + key; const onInputNumber = (numberValue: number) => { - let sectionValue = section.type === 'month' ? numberValue - 1 : numberValue; + let sectionValue = numberValue; const sectionMaxValue = section.maxValue ?? 0; const allowsZero = section.minValue === 0; let shouldResetUserInput; @@ -374,7 +319,7 @@ export function useBaseDateFieldState( } shouldResetUserInput = Number(newValue + '0') > 12; } else if (sectionValue > sectionMaxValue) { - sectionValue = Number(key) - (section.type === 'month' ? 1 : 0); + sectionValue = Number(key); newValue = key; if (sectionValue > sectionMaxValue) { enteredKeys.current = ''; @@ -420,8 +365,8 @@ export function useBaseDateFieldState( const foundValue = foundOptions[0]; const index = options.indexOf(foundValue); - if (section.type === 'dayPeriod') { - setSection(sectionIndex, index === 1 ? 12 : 0); + if (section.type === 'month') { + setSection(sectionIndex, index + 1); } else { setSection(sectionIndex, index); } diff --git a/src/components/DateField/hooks/useDateFieldProps.ts b/src/components/DateField/hooks/useDateFieldProps.ts index ad441c4a..f22c256d 100644 --- a/src/components/DateField/hooks/useDateFieldProps.ts +++ b/src/components/DateField/hooks/useDateFieldProps.ts @@ -146,6 +146,7 @@ export function useDateFieldProps( onBlur(e) { props.onBlur?.(e); setSelectedSections(-1); + state.confirmPlaceholder(); }, onKeyDown(e) { props.onKeyDown?.(e); diff --git a/src/components/DateField/hooks/useDateFieldState.ts b/src/components/DateField/hooks/useDateFieldState.ts index 94898767..086be492 100644 --- a/src/components/DateField/hooks/useDateFieldState.ts +++ b/src/components/DateField/hooks/useDateFieldState.ts @@ -3,21 +3,16 @@ import React from 'react'; import type {DateTime} from '@gravity-ui/date-utils'; import {useControlledState, useLang} from '@gravity-ui/uikit'; -import type {DateFieldBase} from '../../types/datePicker'; +import type {InputBase, Validation, ValueBase} from '../../types'; import {createPlaceholderValue, isInvalid} from '../../utils/dates'; import {useDefaultTimeZone} from '../../utils/useDefaultTimeZone'; -import type { - AvailableSections, - DateFieldSectionType, - DateFieldSectionWithoutPosition, -} from '../types'; +import {IncompleteDate} from '../IncompleteDate'; +import type {DateFieldSectionWithoutPosition} from '../types'; import { addSegment, adjustDateToFormat, getEditableSections, getFormatInfo, - isAllSegmentsValid, - markValidSection, parseDateFromString, setSegment, useFormatSections, @@ -26,7 +21,25 @@ import { import {useBaseDateFieldState} from './useBaseDateFieldState'; import type {DateFieldState} from './useBaseDateFieldState'; -export interface DateFieldStateOptions extends DateFieldBase {} +export interface DateFieldStateOptions extends ValueBase, InputBase, Validation { + /** The minimum allowed date that a user may select. */ + minValue?: DateTime; + /** The maximum allowed date that a user may select. */ + maxValue?: DateTime; + /** Callback that is called for each date of the calendar. If it returns true, then the date is unavailable. */ + isDateUnavailable?: (date: DateTime) => boolean; + /** Format of the date when rendered in the input. [Available formats](https://day.js.org/docs/en/display/format) */ + format?: string; + /** A placeholder date that controls the default values of each segment when the user first interacts with them. Defaults to today's date at midnight. */ + placeholderValue?: DateTime; + /** + * Which timezone use to show values. Example: 'default', 'system', 'Europe/Amsterdam'. + * @default The timezone of the `value` or `defaultValue` or `placeholderValue`, 'default' otherwise. + */ + timeZone?: string; + /** Custom parser function for parsing pasted date strings. If not provided, the default parser will be used. */ + parseDateFromString?: (dateStr: string, format: string, timeZone?: string) => DateTime; +} export function useDateFieldState(props: DateFieldStateOptions): DateFieldState { const [value, setDate] = useControlledState( @@ -44,51 +57,54 @@ export function useDateFieldState(props: DateFieldStateOptions): DateFieldState setDate(v ? v.timeZone(inputTimeZone) : v); }; - const [placeholderDate, setPlaceholderDate] = React.useState(() => { - return createPlaceholderValue({ - placeholderValue: props.placeholderValue, - timeZone, - }); - }); + const [lastPlaceholder, setLastPlaceholder] = React.useState(props.placeholderValue); + if ( + (props.placeholderValue && !props.placeholderValue.isSame(lastPlaceholder)) || + (!props.placeholderValue && lastPlaceholder) + ) { + setLastPlaceholder(props.placeholderValue); + } + const placeholder = React.useMemo( + () => + createPlaceholderValue({ + placeholderValue: lastPlaceholder, + timeZone, + }), + [lastPlaceholder, timeZone], + ); const format = props.format || 'L'; const sections = useFormatSections(format); const formatInfo = React.useMemo(() => getFormatInfo(sections), [sections]); const allSegments = formatInfo.availableUnits; - const validSegmentsState = React.useState(() => - value ? {...allSegments} : {}, - ); - - let validSegments = validSegmentsState[0]; - const setValidSegments = validSegmentsState[1]; - - if (value && !isAllSegmentsValid(allSegments, validSegments)) { - setValidSegments({...allSegments}); - } + const [displayValue, setDisplayValue] = React.useState(() => { + return new IncompleteDate(value && value.isValid() ? value.timeZone(timeZone) : null); + }); + const [lastValue, setLastValue] = React.useState(value); + const [lastTimezone, setLastTimezone] = React.useState(timeZone); if ( - !value && - Object.keys(allSegments).length > 0 && - isAllSegmentsValid(allSegments, validSegments) && - Object.keys(validSegments).length === Object.keys(allSegments).length + (value && !value.isSame(lastValue)) || + (value && lastTimezone !== timeZone) || + (value === null && lastValue !== null) ) { - validSegments = {}; - setValidSegments(validSegments); - setPlaceholderDate( - createPlaceholderValue({ - placeholderValue: props.placeholderValue, - timeZone, - }), - ); + setLastValue(value); + setLastTimezone(timeZone); + setDisplayValue(new IncompleteDate(value?.timeZone(timeZone))); } const {lang} = useLang(); - const displayValue = - value && value.isValid() && isAllSegmentsValid(allSegments, validSegments) - ? value.timeZone(timeZone).locale(lang) - : placeholderDate.timeZone(timeZone).locale(lang); - const sectionsState = useSectionsState(sections, displayValue, validSegments); + const dateValue = React.useMemo(() => { + return displayValue + .toDateTime(value?.timeZone(timeZone) ?? placeholder, { + setDate: formatInfo.hasDate, + setTime: formatInfo.hasTime, + }) + .locale(lang); + }, [displayValue, value, placeholder, formatInfo, timeZone, lang]); + + const sectionsState = useSectionsState(sections, displayValue, dateValue); const [selectedSections, setSelectedSections] = React.useState(-1); @@ -122,88 +138,82 @@ export function useDateFieldState(props: DateFieldStateOptions): DateFieldState return selectedSections; }, [selectedSections, sectionsState.editableSections]); - function setValue(newValue: DateTime) { + function setValue(newValue: DateTime | IncompleteDate | null) { if (props.disabled || props.readOnly) { return; } - if (isAllSegmentsValid(allSegments, validSegments)) { - if (!value || !newValue.isSame(value)) { - handleUpdateDate(adjustDateToFormat(newValue, formatInfo)); - } - } else { - if (value) { - handleUpdateDate(null); + if ( + newValue === null || + (newValue instanceof IncompleteDate && newValue.isCleared(allSegments)) + ) { + setDate(null); + setDisplayValue(new IncompleteDate()); + } else if (newValue instanceof IncompleteDate) { + if (newValue.isComplete(allSegments)) { + const newDate = newValue.toDateTime(dateValue, { + setDate: formatInfo.hasDate, + setTime: formatInfo.hasTime, + }); + if (newValue.validate(newDate, allSegments)) { + if (!value || !newDate.isSame(value)) { + handleUpdateDate(adjustDateToFormat(newDate, formatInfo)); + } + } } - setPlaceholderDate(newValue); + setDisplayValue(newValue); + } else if (!value || !newValue.isSame(value)) { + handleUpdateDate(newValue); } } - function markValid(part: DateFieldSectionType) { - validSegments = markValidSection(allSegments, validSegments, part); - setValidSegments({...validSegments}); - } - function setSection(sectionIndex: number, amount: number) { const section = sectionsState.editableSections[sectionIndex]; if (section) { - markValid(section.type); - setValue(setSegment(section, displayValue, amount)); + setValue(setSegment(section, displayValue, amount, dateValue)); } } function adjustSection(sectionIndex: number, amount: number) { const section = sectionsState.editableSections[sectionIndex]; if (section) { - if (validSegments[section.type]) { - setValue(addSegment(section, displayValue, amount)); - } else { - markValid(section.type); - if (Object.keys(validSegments).length >= Object.keys(allSegments).length) { - setValue(displayValue); - } - } + setValue(addSegment(section, displayValue, amount, dateValue)); } } - function flushValidSection(sectionIndex: number) { - const section = sectionsState.editableSections[sectionIndex]; - if (section) { - delete validSegments[section.type]; - } - setValidSegments({...validSegments}); - } - - function flushAllValidSections() { - validSegments = {}; - setValidSegments({}); - } - function getSectionValue(_sectionIndex: number) { return displayValue; } - function setSectionValue(_sectionIndex: number, currentValue: DateTime) { - setValue(currentValue); - } - - function createPlaceholder() { - return createPlaceholderValue({ - placeholderValue: props.placeholderValue, - timeZone, - }).timeZone(timeZone); + function setSectionValue(_sectionIndex: number, newValue: IncompleteDate) { + setValue(newValue); } function setValueFromString(str: string) { const parseDate = props.parseDateFromString ?? parseDateFromString; const date = parseDate(str, format, timeZone); if (date.isValid()) { - handleUpdateDate(date); + setValue(date); return true; } return false; } + function confirmPlaceholder() { + if (props.disabled || props.readOnly) { + return; + } + + // If the display value is complete but invalid, we need to constrain it and emit onChange on blur. + if (displayValue.isComplete(allSegments)) { + const newValue = displayValue.toDateTime(dateValue, { + setDate: formatInfo.hasDate, + setTime: formatInfo.hasTime, + }); + setValue(adjustDateToFormat(newValue, formatInfo, 'startOf')); + } + } + const validationState = props.validationState || (isInvalid(value, props.minValue, props.maxValue) ? 'invalid' : undefined) || @@ -211,7 +221,7 @@ export function useDateFieldState(props: DateFieldStateOptions): DateFieldState return useBaseDateFieldState({ value, - displayValue, + displayValue: dateValue, placeholderValue: props.placeholderValue, timeZone, validationState, @@ -221,47 +231,38 @@ export function useDateFieldState(props: DateFieldStateOptions): DateFieldState disabled: props.disabled, selectedSectionIndexes, selectedSections, - isEmpty: Object.keys(validSegments).length === 0, - flushAllValidSections, - flushValidSection, + isEmpty: displayValue.isCleared(allSegments), setSelectedSections, setValue, - setDate: handleUpdateDate, adjustSection, setSection, getSectionValue, setSectionValue, - createPlaceholder, setValueFromString, + confirmPlaceholder, }); } function useSectionsState( sections: DateFieldSectionWithoutPosition[], - value: DateTime, - validSegments: AvailableSections, + value: IncompleteDate, + placeholder: DateTime, ) { const [state, setState] = React.useState(() => { return { value, sections, - validSegments, - editableSections: getEditableSections(sections, value, validSegments), + placeholder, + editableSections: getEditableSections(sections, value, placeholder), }; }); - if ( - sections !== state.sections || - validSegments !== state.validSegments || - !value.isSame(state.value) || - value.timeZone() !== state.value.timeZone() || - value.locale() !== state.value.locale() - ) { + if (sections !== state.sections || placeholder !== state.placeholder || value !== state.value) { setState({ value, sections, - validSegments, - editableSections: getEditableSections(sections, value, validSegments), + placeholder, + editableSections: getEditableSections(sections, value, placeholder), }); } diff --git a/src/components/DateField/i18n/ru.json b/src/components/DateField/i18n/ru.json index 3b73f9f9..cbf09ae9 100644 --- a/src/components/DateField/i18n/ru.json +++ b/src/components/DateField/i18n/ru.json @@ -2,7 +2,7 @@ "year_placeholder": "Г", "quarter_placeholder": "K", "month_placeholder": "М", - "weekday_placeholder": "ДН", + "weekday_placeholder": "д", "day_placeholder": "Д", "hour_placeholder": "ч", "minute_placeholder": "м", diff --git a/src/components/DateField/types.ts b/src/components/DateField/types.ts index 70ee7f2e..d918e3d5 100644 --- a/src/components/DateField/types.ts +++ b/src/components/DateField/types.ts @@ -22,7 +22,7 @@ export type DateFormatTokenMap = { }; export interface DateFieldSection { - value: number | undefined; + value: number | null; /** * Value of the section, as rendered inside the input. * For example, in the date `May 25, 1995`, the value of the month section is "May". diff --git a/src/components/DateField/utils.ts b/src/components/DateField/utils.ts index 1e852111..1c7fa767 100644 --- a/src/components/DateField/utils.ts +++ b/src/components/DateField/utils.ts @@ -9,6 +9,7 @@ import {useLang} from '@gravity-ui/uikit'; import type {ExtractFunctionType} from '../types'; import {mergeDateTime} from '../utils/dates'; +import type {IncompleteDate} from './IncompleteDate'; import {i18n} from './i18n'; import type { AvailableSections, @@ -19,17 +20,17 @@ import type { FormatInfo, } from './types'; -export const EDITABLE_SEGMENTS: AvailableSections = { +export const EDITABLE_SEGMENTS = { year: true, quarter: true, month: true, day: true, + weekday: true, hour: true, minute: true, second: true, dayPeriod: true, - weekday: true, -}; +} satisfies AvailableSections; const escapedCharacters = {start: '[', end: ']'}; @@ -40,6 +41,7 @@ const formatTokenMap: DateFormatTokenMap = { // Quarter Q: 'quarter', + Qo: 'quarter', // Month M: 'month', @@ -83,6 +85,17 @@ const formatTokenMap: DateFormatTokenMap = { ZZ: {sectionType: 'timeZoneName', contentType: 'letter'}, }; +export const PAGE_STEP: Partial> = { + year: 5, + quarter: 2, + month: 2, + weekday: 3, + day: 7, + hour: 2, + minute: 15, + second: 15, +}; + function getDateSectionConfigFromFormatToken(formatToken: string): { type: DateFieldSectionType; contentType: 'letter' | 'digit'; @@ -123,7 +136,11 @@ function isHour12(format: string) { return dateTime().set('hour', 15).format(format) !== '15'; } -export function getSectionLimits(section: DateFieldSectionWithoutPosition, date: DateTime) { +function getSectionLimits( + section: DateFieldSectionWithoutPosition, + date: IncompleteDate, + placeholder: DateTime, +) { const {type, format} = section; switch (type) { case 'year': { @@ -138,8 +155,8 @@ export function getSectionLimits(section: DateFieldSectionWithoutPosition, date: } case 'month': { return { - minValue: 0, - maxValue: 11, + minValue: 1, + maxValue: 12, }; } case 'weekday': { @@ -151,12 +168,12 @@ export function getSectionLimits(section: DateFieldSectionWithoutPosition, date: case 'day': { return { minValue: 1, - maxValue: date ? date.daysInMonth() : 31, + maxValue: 31, }; } case 'hour': { if (isHour12(format)) { - const isPM = date.hour() >= 12; + const isPM = (date.hour ?? placeholder.hour()) >= 12; return { minValue: isPM ? 12 : 0, maxValue: isPM ? 23 : 11, @@ -178,32 +195,29 @@ export function getSectionLimits(section: DateFieldSectionWithoutPosition, date: return {}; } -export function getSectionValue(section: DateFieldSectionWithoutPosition, date: DateTime) { +function getSectionValue(section: DateFieldSectionWithoutPosition, date: IncompleteDate) { const type = section.type; switch (type) { case 'year': { + if (date.year === null) { + return null; + } return isFourDigitYearFormat(section.format) - ? date.year() - : Number(date.format(section.format)); + ? date.year + : Number(dateTime().set('year', date.year).format(section.format)); } case 'quarter': case 'month': + case 'weekday': case 'hour': case 'minute': - case 'second': { - return date[type](); - } - case 'day': { - return date.date(); - } - case 'weekday': { - return date.day(); - } + case 'second': + case 'day': case 'dayPeriod': { - return date.hour() >= 12 ? 12 : 0; + return date[type]; } } - return undefined; + return null; } const TYPE_MAPPING = { @@ -213,7 +227,7 @@ const TYPE_MAPPING = { } as const; export function getDurationUnitFromSectionType(type: DateFieldSectionType) { - if (type === 'literal' || type === 'timeZoneName' || type === 'unknown') { + if (!isEditableSectionType(type)) { throw new Error(`${type} section does not have duration unit.`); } @@ -227,10 +241,55 @@ export function getDurationUnitFromSectionType(type: DateFieldSectionType) { >; } -export function addSegment(section: DateFieldSection, date: DateTime, amount: number) { - let val = section.value ?? 0; +export function addSegment( + section: DateFieldSection, + date: IncompleteDate, + amount: number, + placeholder: DateTime, +) { + if (!isEditableSectionType(section.type)) { + throw new Error(); + } + let val = date[section.type]; + if (val === null) { + let newDate; + if (section.type === 'quarter' || section.type === 'month') { + newDate = date.set('month', placeholder.month() + 1); + } else if (section.type === 'dayPeriod') { + newDate = date.set('hour', placeholder.hour()); + } else if (section.type === 'weekday' && date.day && date.day !== placeholder.date()) { + newDate = date.set('weekday', placeholder.set({date: date.day}).day()); + } else { + newDate = date.set( + section.type, + placeholder[getDurationUnitFromSectionType(section.type)](), + ); + } + + if ( + newDate.weekday !== null && + (section.type === 'day' || + section.type === 'month' || + section.type === 'quarter' || + section.type === 'year') && + newDate.year !== null && + newDate.month !== null && + newDate.day !== null + ) { + newDate = newDate.set( + 'weekday', + placeholder + .set({date: newDate.day, month: newDate.month, year: newDate.year}) + .day(), + ); + } + + return newDate; + } + if (section.type === 'dayPeriod') { - val = date.hour() + (date.hour() >= 12 ? -12 : 12); + const hour = date.hour ?? placeholder.hour(); + val = hour + (hour >= 12 ? -12 : 12); } else { val = val + amount; const min = section.minValue; @@ -241,23 +300,51 @@ export function addSegment(section: DateFieldSection, date: DateTime, amount: nu } } + if (section.type === 'dayPeriod') { + return date.set('hour', val); + } + if (section.type === 'year' && !isFourDigitYearFormat(section.format)) { val = dateTime({input: `${val}`.padStart(2, '0'), format: section.format}).year(); } - if (section.type === 'quarter') { - return date.set(getDurationUnitFromSectionType('month'), val * 3 - 1); + const newDate = date.set(section.type, val); + + if (newDate.year !== null && newDate.month !== null && newDate.day !== null) { + if ( + date.weekday !== null && + (section.type === 'day' || + section.type === 'month' || + section.type === 'quarter' || + section.type === 'year') + ) { + newDate.weekday = placeholder + .set({date: newDate.day, month: newDate.month, year: newDate.year}) + .day(); + } else if (section.type === 'weekday') { + const d = placeholder + .set({date: newDate.day, month: newDate.month, year: newDate.year}) + .set({day: val}); + newDate.year = d.year(); + newDate.month = d.month(); + newDate.day = d.date(); + } } - const type = getDurationUnitFromSectionType(section.type); - return date.set(type, val); + return newDate; } -export function setSegment(section: DateFieldSection, date: DateTime, amount: number) { +export function setSegment( + section: DateFieldSectionWithoutPosition, + date: IncompleteDate, + amount: number, + placeholder: DateTime, +) { const type = section.type; + let newDate; switch (type) { case 'year': { - return date.set( + newDate = date.set( 'year', isFourDigitYearFormat(section.format) ? amount @@ -266,29 +353,31 @@ export function setSegment(section: DateFieldSection, date: DateTime, amount: nu format: section.format, }).year(), ); + break; } - case 'quarter': { - return date.set(getDurationUnitFromSectionType('month'), amount * 3 - 1); - } - case 'day': + case 'quarter': + case 'month': case 'weekday': - case 'month': { - return date.set(getDurationUnitFromSectionType(type), amount); + case 'day': { + newDate = date.set(type, amount); + break; } case 'dayPeriod': { - const hours = date.hour(); + const hours = date.hour ?? placeholder.hour(); const wasPM = hours >= 12; - const isPM = amount >= 12; + const isPM = amount === 1; if (isPM === wasPM) { - return date; + newDate = date; + } else { + newDate = date.set('hour', wasPM ? hours - 12 : hours + 12); } - return date.set('hour', wasPM ? hours - 12 : hours + 12); + break; } case 'hour': { // In 12 hour time, ensure that AM/PM does not change let sectionAmount = amount; if (section.minValue === 12 || section.maxValue === 11) { - const hours = date.hour(); + const hours = date.hour ?? placeholder.hour(); const wasPM = hours >= 12; if (!wasPM && sectionAmount === 12) { sectionAmount = 0; @@ -297,15 +386,42 @@ export function setSegment(section: DateFieldSection, date: DateTime, amount: nu sectionAmount += 12; } } - return date.set('hour', sectionAmount); + newDate = date.set('hour', sectionAmount); + break; } case 'minute': case 'second': { - return date.set(type, amount); + newDate = date.set(type, amount); + break; + } + default: { + newDate = date; + break; } } - return date; + if (newDate.year !== null && newDate.month !== null && newDate.day !== null) { + if ( + newDate.weekday !== null && + (type === 'day' || type === 'month' || type === 'quarter' || type === 'year') + ) { + newDate = newDate.set( + 'weekday', + placeholder + .set({date: newDate.day, month: newDate.month, year: newDate.year}) + .day(), + ); + } else if (type === 'weekday') { + const d = placeholder + .set({date: newDate.day, month: newDate.month, year: newDate.year}) + .set({day: amount}); + newDate.year = d.year(); + newDate.month = d.month(); + newDate.day = d.date(); + } + } + + return newDate; } function doesSectionHaveLeadingZeros( @@ -373,7 +489,7 @@ function getSectionPlaceholder( } case 'quarter': { - return t('quarter_placeholder'); + return t('quarter_placeholder').repeat(currentTokenValue.length); } case 'month': { @@ -385,7 +501,9 @@ function getSectionPlaceholder( } case 'weekday': { - return t('weekday_placeholder').repeat(sectionConfig.contentType === 'letter' ? 4 : 2); + return t('weekday_placeholder').repeat( + sectionConfig.contentType === 'letter' ? currentTokenValue.length : 2, + ); } case 'hour': { @@ -546,8 +664,8 @@ export function cleanString(dirtyString: string) { export function getEditableSections( sections: DateFieldSectionWithoutPosition[], - value: DateTime, - validSegments: AvailableSections, + value: IncompleteDate, + placeholder: DateTime, ) { let position = 1; const newSections: DateFieldSection[] = []; @@ -561,14 +679,14 @@ export function getEditableSections( const newSection = toEditableSection( section, value, - validSegments, + placeholder, position, previousEditableSection, ); newSections.push(newSection); - if (isEditableSection(section)) { + if (isEditableSectionType(section.type)) { for (let j = Math.max(0, previousEditableSection); j <= i; j++) { const prevSection = newSections[j]; if (prevSection) { @@ -587,21 +705,40 @@ export function getEditableSections( return newSections; } -export function isEditableSection(section: DateFieldSectionWithoutPosition): boolean { - return EDITABLE_SEGMENTS[section.type] ?? false; +export function isEditableSectionType( + type: DateFieldSectionType, +): type is keyof typeof EDITABLE_SEGMENTS { + return EDITABLE_SEGMENTS[type as keyof typeof EDITABLE_SEGMENTS] ?? false; } export function toEditableSection( section: DateFieldSectionWithoutPosition, - value: DateTime, - validSegments: AvailableSections, + value: IncompleteDate, + placeholder: DateTime, position: number, previousEditableSection: number, ): DateFieldSection { - const isEditable = isEditableSection(section); let renderedValue = section.placeholder; - if ((isEditable && validSegments[section.type]) || section.type === 'timeZoneName') { - renderedValue = value.format(section.format); + let val = isEditableSectionType(section.type) ? value[section.type] : null; + if (section.type === 'timeZoneName') { + renderedValue = placeholder.format(section.format); + } else if (isEditableSectionType(section.type) && val !== null) { + const sectionDate = placeholder.set({month: 0, date: 1, hour: 0, minute: 0, second: 0}); + let sectionType = getDurationUnitFromSectionType(section.type); + if (section.type === 'month') { + val -= 1; + } else if (section.type === 'quarter') { + sectionType = 'month'; + val = (val - 1) * 3; + } else if (section.type === 'dayPeriod') { + if (value.hour === null) { + val = val === 1 ? 12 : 0; + } else { + val = value.hour; + } + } + + renderedValue = sectionDate.set(sectionType, val).format(section.format); if (section.contentType === 'digit' && renderedValue.length < section.placeholder.length) { renderedValue = renderedValue.padStart(section.placeholder.length, '0'); } @@ -621,7 +758,7 @@ export function toEditableSection( modified: false, previousEditableSection, nextEditableSection: previousEditableSection, - ...getSectionLimits(section, value), + ...getSectionLimits(section, value, placeholder), }; return newSection; @@ -634,7 +771,7 @@ export function getCurrentEditableSectionIndex( const currentIndex = selectedSections === 'all' || selectedSections === -1 ? 0 : selectedSections; const section = sections[currentIndex]; - if (section && !EDITABLE_SEGMENTS[section.type]) { + if (section && !isEditableSectionType(section.type)) { return section.nextEditableSection; } return section ? currentIndex : -1; @@ -679,13 +816,6 @@ export function parseDateFromString(str: string, format: string, timeZone?: stri return date; } -export function isAllSegmentsValid( - allSegments: AvailableSections, - validSegments: AvailableSections, -) { - return Object.keys(allSegments).every((key) => validSegments[key as keyof AvailableSections]); -} - export function useFormatSections(format: string) { const {t} = i18n.useTranslation(); const {lang} = useLang(); @@ -710,7 +840,7 @@ export function getFormatInfo(sections: DateFieldSectionWithoutPosition[]): Form let minDateUnitIndex = dateUnits.length - 1; let minTimeUnitIndex = timeUnits.length - 1; for (const s of sections) { - if (!isEditableSection(s)) { + if (!isEditableSectionType(s.type)) { continue; } const dateUnitIndex = dateUnits.indexOf(s.type as any); @@ -750,26 +880,3 @@ export function adjustDateToFormat( return newDate; } - -export function markValidSection( - allSections: AvailableSections, - editableSections: AvailableSections, - unit: DateFieldSectionType, -) { - const validSections = {...editableSections}; - validSections[unit] = true; - if (validSections.day && validSections.month && validSections.year && allSections.weekday) { - validSections.weekday = true; - } - if (validSections.month && allSections.quarter) { - validSections.quarter = true; - } - if (validSections.quarter && allSections.month) { - validSections.month = true; - } - if (validSections.hour && allSections.dayPeriod) { - validSections.dayPeriod = true; - } - - return validSections; -} diff --git a/src/components/HiddenInput/HiddenInput.tsx b/src/components/HiddenInput/HiddenInput.tsx index 9534679e..ef0e851a 100644 --- a/src/components/HiddenInput/HiddenInput.tsx +++ b/src/components/HiddenInput/HiddenInput.tsx @@ -26,7 +26,7 @@ export function HiddenInput({ return ; } -export function useFormResetHandler({ +function useFormResetHandler({ initialValue, onReset, }: { diff --git a/src/components/RangeDateField/RangeDateField.tsx b/src/components/RangeDateField/RangeDateField.tsx index 41df5104..0e800226 100644 --- a/src/components/RangeDateField/RangeDateField.tsx +++ b/src/components/RangeDateField/RangeDateField.tsx @@ -51,7 +51,7 @@ export function RangeDateField({className, ...props}: RangeDateFieldProps) { name={props.name} form={props.form} onReset={(v) => { - state.setDate(v); + state.setValue(v); }} value={state.value ?? null} toStringValue={(v) => (v ? v.start.toISOString() : '')} diff --git a/src/components/RangeDateField/__stories__/RangeDateField.stories.tsx b/src/components/RangeDateField/__stories__/RangeDateField.stories.tsx index 586d1f6e..151bc299 100644 --- a/src/components/RangeDateField/__stories__/RangeDateField.stories.tsx +++ b/src/components/RangeDateField/__stories__/RangeDateField.stories.tsx @@ -26,9 +26,9 @@ export const Default = meta.story({ ...args, minValue: args.minValue ? dateTimeParse(args.minValue, {timeZone}) : undefined, maxValue: args.maxValue ? dateTimeParse(args.maxValue, {timeZone}) : undefined, - value: args.value ? parseRangeDateTime(args.value, args.format, timeZone) : undefined, + value: args.value ? parseRangeDateTime(args.value, timeZone) : undefined, defaultValue: args.defaultValue - ? parseRangeDateTime(args.defaultValue, args.format, timeZone) + ? parseRangeDateTime(args.defaultValue, timeZone) : undefined, placeholderValue: args.placeholderValue ? dateTimeParse(args.placeholderValue, {timeZone}) @@ -95,9 +95,9 @@ export const Default = meta.story({ }); // eslint-disable-next-line @typescript-eslint/no-explicit-any -function parseRangeDateTime(text: any, format?: string, timeZone?: string) { +function parseRangeDateTime(text: any, timeZone?: string) { const list = text.split('-'); - const start = dateTimeParse(list?.[0]?.trim(), {format, timeZone}) ?? dateTime(); - const end = dateTimeParse(list?.[1]?.trim(), {format, timeZone}) ?? dateTime(); + const start = dateTimeParse(list?.[0]?.trim(), {timeZone}) ?? dateTime(); + const end = dateTimeParse(list?.[1]?.trim(), {timeZone}) ?? dateTime(); return {start, end}; } diff --git a/src/components/RangeDateField/__tests__/RangeDateField.test.tsx b/src/components/RangeDateField/__tests__/RangeDateField.test.tsx new file mode 100644 index 00000000..01af393f --- /dev/null +++ b/src/components/RangeDateField/__tests__/RangeDateField.test.tsx @@ -0,0 +1,35 @@ +import {dateTime} from '@gravity-ui/date-utils'; +import {describe, expect, it, vi} from 'vitest'; +import {userEvent} from 'vitest/browser'; + +import {render} from '#test-utils/utils'; + +import {RangeDateField} from '../RangeDateField'; + +describe('RangeDateField', () => { + describe('invalid date', () => { + it('should allow to enter 31 april - 31 april and constrains the range on blur', async () => { + const onUpdate = vi.fn(); + const timeZone = 'Europe/Amsterdam'; + await render( + , + ); + await userEvent.keyboard('{Tab}'); + await userEvent.keyboard('3104202431042024'); + + expect(onUpdate).not.toHaveBeenCalled(); + + await userEvent.keyboard('{Tab}'); + + const expectedStart = dateTime({input: '2024-04-30', timeZone}); + expect(onUpdate).toHaveBeenCalledWith({ + start: expectedStart.startOf('day'), + end: expectedStart.endOf('day'), + }); + }); + }); +}); diff --git a/src/components/RangeDateField/__tests__/utils.test.ts b/src/components/RangeDateField/__tests__/utils.test.ts index 968b1bd6..79bbd5cc 100644 --- a/src/components/RangeDateField/__tests__/utils.test.ts +++ b/src/components/RangeDateField/__tests__/utils.test.ts @@ -1,10 +1,11 @@ import {dateTime} from '@gravity-ui/date-utils'; import {expect, test} from 'vitest'; +import {IncompleteDate} from '../../DateField/IncompleteDate'; import { cleanString, formatSections, - isEditableSection, + isEditableSectionType, splitFormatIntoSections, } from '../../DateField/utils'; import {getRangeEditableSections} from '../utils'; @@ -14,21 +15,17 @@ test('create a valid sequence of editable sections for range', () => { const sections = splitFormatIntoSections(format); const date = dateTime({input: [2024, 1, 14, 12, 30, 0]}); const range = {start: date, end: date}; - const eSections = getRangeEditableSections( - sections, - range, - { - start: {month: true}, - end: {year: true}, - }, - ' — ', - ); + 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((section, index) => (isEditableSection(section) ? index : null!)) // eslint-disable-line @typescript-eslint/no-non-null-assertion - .filter((entry) => entry !== null); + .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); @@ -37,7 +34,7 @@ test('create a valid sequence of editable sections for range', () => { let position = 1; for (let i = 0; i < eSections.length; i++) { const section = eSections[i]; - if (isEditableSection(section)) { + if (isEditableSectionType(section.type)) { pointer++; } diff --git a/src/components/RangeDateField/hooks/useRangeDateFieldState.test.ts b/src/components/RangeDateField/hooks/useRangeDateFieldState.test.ts index bbf89dac..8180d677 100644 --- a/src/components/RangeDateField/hooks/useRangeDateFieldState.test.ts +++ b/src/components/RangeDateField/hooks/useRangeDateFieldState.test.ts @@ -4,7 +4,7 @@ import {expect, test, vitest} from 'vitest'; import {renderHook} from '#test-utils/utils'; -import {cleanString, isEditableSection} from '../../DateField/utils'; +import {cleanString, isEditableSectionType} from '../../DateField/utils'; import type {RangeValue} from '../../types'; import {useRangeDateFieldState} from './useRangeDateFieldState'; @@ -45,7 +45,7 @@ test('can navigate through the range and change sections', async () => { act(() => result.current.incrementPage()); act(() => result.current.incrementPage()); - const position = result.current.sections.filter((e) => isEditableSection(e))[4]?.end; + const position = result.current.sections.filter((e) => isEditableSectionType(e.type))[4]?.end; act(() => result.current.focusSectionInPosition(position)); act(() => result.current.increment()); @@ -79,14 +79,16 @@ test('call onUpdate only if the entire value is valid', async () => { act(() => result.current.focusPreviousSection()); act(() => result.current.incrementToMax()); - expect(onUpdateSpy).not.toHaveBeenCalled(); - act(() => result.current.focusLastSection()); act(() => result.current.increment()); - expect(cleanString(result.current.text)).toBe('31.01.2024 — 29.02.2024'); + expect(onUpdateSpy).not.toHaveBeenCalled(); + + expect(cleanString(result.current.text)).toBe('31.01.2024 — 31.02.2024'); + + act(() => result.current.confirmPlaceholder()); - expect(onUpdateSpy).toHaveBeenLastCalledWith({ + 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'), }); @@ -126,7 +128,7 @@ test('can clear the section or the entire range', async () => { expect(cleanString(result.current.text)).toBe('20.01.2024 — 24.01.2024'); - const position = result.current.sections.filter((e) => isEditableSection(e))[4]?.end; + const position = result.current.sections.filter((e) => isEditableSectionType(e.type))[4]?.end; act(() => result.current.focusSectionInPosition(position)); act(() => result.current.clearSection()); diff --git a/src/components/RangeDateField/hooks/useRangeDateFieldState.ts b/src/components/RangeDateField/hooks/useRangeDateFieldState.ts index b7028436..fed4cb52 100644 --- a/src/components/RangeDateField/hooks/useRangeDateFieldState.ts +++ b/src/components/RangeDateField/hooks/useRangeDateFieldState.ts @@ -5,14 +5,12 @@ import {useControlledState, useLang} from '@gravity-ui/uikit'; import {useBaseDateFieldState} from '../../DateField'; import type {DateFieldState} from '../../DateField'; -import type {DateFieldSectionType, DateFieldSectionWithoutPosition} from '../../DateField/types'; -import type {EDITABLE_SEGMENTS} from '../../DateField/utils'; +import {IncompleteDate} from '../../DateField/IncompleteDate.js'; +import type {DateFieldSectionWithoutPosition} from '../../DateField/types'; import { addSegment, adjustDateToFormat, getFormatInfo, - isAllSegmentsValid, - markValidSection, parseDateFromString, setSegment, useFormatSections, @@ -49,67 +47,60 @@ export function useRangeDateFieldState(props: RangeDateFieldStateOptions): Range ); }; - const [placeholderDate, setPlaceholderDate] = React.useState>(() => { + const placeholder = React.useMemo(() => { return createPlaceholderRangeValue({ placeholderValue: props.placeholderValue, timeZone, }); - }); + }, [props.placeholderValue, timeZone]); const format = props.format || 'L'; const delimiter = props.delimiter ?? RANGE_FORMAT_DELIMITER; const sections = useFormatSections(format); const formatInfo = React.useMemo(() => getFormatInfo(sections), [sections]); - const allSegments = formatInfo.availableUnits; - // eslint-disable-next-line prefer-const - let [validSegments, setValidSegments] = React.useState>( - () => (value ? {start: {...allSegments}, end: {...allSegments}} : {start: {}, end: {}}), - ); - - if ( - value && - (!isAllSegmentsValid(allSegments, validSegments.start) || - !isAllSegmentsValid(allSegments, validSegments.end)) - ) { - setValidSegments({start: {...allSegments}, end: {...allSegments}}); - } + const [displayValue, setDisplayValue] = React.useState(() => { + return { + start: new IncompleteDate(value ? value.start.timeZone(timeZone) : null), + end: new IncompleteDate(value ? value.end.timeZone(timeZone) : null), + }; + }); + const [lastValue, setLastValue] = React.useState(value); + const [lastTimezone, setLastTimezone] = React.useState(timeZone); if ( - !value && - Object.keys(allSegments).length > 0 && - isAllSegmentsValid(allSegments, validSegments.start) && - Object.keys(validSegments.start).length === Object.keys(allSegments).length && - isAllSegmentsValid(allSegments, validSegments.end) && - Object.keys(validSegments.end).length === Object.keys(allSegments).length + (value && !(value.start.isSame(lastValue?.start) && value.end.isSame(lastValue?.end))) || + (value && lastTimezone !== timeZone) || + (value === null && lastValue !== null) ) { - validSegments = {start: {}, end: {}}; - setValidSegments(validSegments); - setPlaceholderDate( - createPlaceholderRangeValue({ - placeholderValue: props.placeholderValue, - timeZone, - }), - ); + setLastValue(value); + setLastTimezone(timeZone); + setDisplayValue({ + start: new IncompleteDate(value ? value.start.timeZone(timeZone) : null), + end: new IncompleteDate(value ? value.end.timeZone(timeZone) : null), + }); } const {lang} = useLang(); - const displayValue = - value && - value.start.isValid() && - value.end.isValid() && - Object.keys(validSegments.start).length >= Object.keys(allSegments).length && - Object.keys(validSegments.end).length >= Object.keys(allSegments).length - ? { - start: value.start.timeZone(timeZone).locale(lang), - end: value.end.timeZone(timeZone).locale(lang), - } - : { - start: placeholderDate.start.timeZone(timeZone).locale(lang), - end: placeholderDate.end.timeZone(timeZone).locale(lang), - }; - const sectionsState = useSectionsState(sections, displayValue, validSegments, delimiter); + const rangeValue = React.useMemo(() => { + return { + start: displayValue.start + .toDateTime(value?.start.timeZone(timeZone) ?? placeholder.start, { + setDate: formatInfo.hasDate, + setTime: formatInfo.hasTime, + }) + .locale(lang), + end: displayValue.end + .toDateTime(value?.end.timeZone(timeZone) ?? placeholder.end, { + setDate: formatInfo.hasDate, + setTime: formatInfo.hasTime, + }) + .locale(lang), + }; + }, [displayValue, value, placeholder, formatInfo, timeZone, lang]); + + const sectionsState = useSectionsState(sections, displayValue, rangeValue, delimiter); const [selectedSections, setSelectedSections] = React.useState(-1); @@ -143,88 +134,90 @@ export function useRangeDateFieldState(props: RangeDateFieldStateOptions): Range return selectedSections; }, [selectedSections, sectionsState.editableSections]); - function setValue(newValue: RangeValue) { + function setValue(newValue: RangeValue | RangeValue | null) { if (props.disabled || props.readOnly) { return; } if ( - isAllSegmentsValid(allSegments, validSegments.start) && - isAllSegmentsValid(allSegments, validSegments.end) + newValue === null || + (newValue.start instanceof IncompleteDate && + newValue.start.isCleared(allSegments) && + newValue.end instanceof IncompleteDate && + newValue.end.isCleared(allSegments)) ) { - if (!value || !(newValue.start.isSame(value.start) && newValue.end.isSame(value.end))) { - handleUpdateRange({ - start: adjustDateToFormat(newValue.start, formatInfo, 'startOf'), - end: adjustDateToFormat(newValue.end, formatInfo, 'endOf'), - }); - } - } else { - if (value) { - handleUpdateRange(null); + setRange(null); + setDisplayValue({start: new IncompleteDate(), end: new IncompleteDate()}); + } else if ( + newValue.start instanceof IncompleteDate && + newValue.end instanceof IncompleteDate + ) { + if (newValue.start.isComplete(allSegments) && newValue.end.isComplete(allSegments)) { + const newRange = { + start: newValue.start.toDateTime(rangeValue.start, { + setDate: formatInfo.hasDate, + setTime: formatInfo.hasTime, + }), + end: newValue.end.toDateTime(rangeValue.end, { + setDate: formatInfo.hasDate, + setTime: formatInfo.hasTime, + }), + }; + if ( + newValue.start.validate(newRange.start, allSegments) && + newValue.end.validate(newRange.end, allSegments) + ) { + if ( + !value || + !(newRange.start.isSame(value.start) && newRange.end.isSame(value.end)) + ) { + handleUpdateRange({ + start: adjustDateToFormat(newRange.start, formatInfo, 'startOf'), + end: adjustDateToFormat(newRange.end, formatInfo, 'endOf'), + }); + } + } } - setPlaceholderDate(newValue); + setDisplayValue({start: newValue.start, end: newValue.end}); + } else if ( + !(newValue.start instanceof IncompleteDate) && + !(newValue.end instanceof IncompleteDate) && + (!value || !(value.start.isSame(newValue.start) && value.end.isSame(newValue.end))) + ) { + handleUpdateRange({start: newValue.start, end: newValue.end}); } } - function markValid(portion: 'start' | 'end', part: DateFieldSectionType) { - validSegments[portion] = markValidSection(allSegments, validSegments[portion], part); - setValidSegments({...validSegments, [portion]: {...validSegments[portion]}}); - } - function setSection(sectionIndex: number, amount: number) { const portion = sectionIndex <= sections.length ? 'start' : 'end'; const section = sectionsState.editableSections[sectionIndex]; - markValid(portion, section.type); setValue({ ...displayValue, - [portion]: setSegment(section, displayValue[portion], amount), + [portion]: setSegment(section, displayValue[portion], amount, rangeValue[portion]), }); } function adjustSection(sectionIndex: number, amount: number) { const section = sectionsState.editableSections[sectionIndex]; const portion = sectionIndex <= sections.length ? 'start' : 'end'; - if (validSegments[portion][section.type]) { + if (section) { setValue({ ...displayValue, - [portion]: addSegment(section, displayValue[portion], amount), + [portion]: addSegment(section, displayValue[portion], amount, rangeValue[portion]), }); - } else { - markValid(portion, section.type); - if (Object.keys(validSegments[portion]).length >= Object.keys(allSegments).length) { - setValue(displayValue); - } } } - function flushValidSection(sectionIndex: number) { - const portion = sectionIndex <= sections.length ? 'start' : 'end'; - delete validSegments[portion][sectionsState.editableSections[sectionIndex].type]; - setValidSegments({...validSegments, [portion]: {...validSegments[portion]}}); - } - - function flushAllValidSections() { - validSegments = {start: {}, end: {}}; - setValidSegments({start: {}, end: {}}); - } - function getSectionValue(sectionIndex: number) { const portion = sectionIndex <= sections.length ? 'start' : 'end'; return displayValue[portion]; } - function setSectionValue(sectionIndex: number, currentValue: DateTime) { + function setSectionValue(sectionIndex: number, currentValue: IncompleteDate) { const portion = sectionIndex <= sections.length ? 'start' : 'end'; setValue({...displayValue, [portion]: currentValue}); } - function createPlaceholder() { - return createPlaceholderRangeValue({ - placeholderValue: props.placeholderValue, - timeZone, - }); - } - function setValueFromString(str: string) { const [startDate = '', endDate = ''] = str.split(delimiter); const parseDate = props.parseDateFromString ?? parseDateFromString; @@ -237,6 +230,38 @@ export function useRangeDateFieldState(props: RangeDateFieldStateOptions): Range return false; } + function confirmPlaceholder() { + if (props.disabled || props.readOnly) { + return; + } + + // If the display value is complete but invalid, we need to constrain it and emit onChange on blur. + if ( + displayValue.start.isComplete(allSegments) && + displayValue.end.isComplete(allSegments) + ) { + const newValue = { + start: adjustDateToFormat( + displayValue.start.toDateTime(rangeValue.start, { + setDate: formatInfo.hasDate, + setTime: formatInfo.hasTime, + }), + formatInfo, + 'startOf', + ), + end: adjustDateToFormat( + displayValue.end.toDateTime(rangeValue.end, { + setDate: formatInfo.hasDate, + setTime: formatInfo.hasTime, + }), + formatInfo, + 'endOf', + ), + }; + setValue(newValue); + } + } + const validationState = props.validationState || (isInvalid(value?.start, props.minValue, props.maxValue) ? 'invalid' : undefined) || @@ -245,9 +270,9 @@ export function useRangeDateFieldState(props: RangeDateFieldStateOptions): Range (value && props.isDateUnavailable?.(value.start) ? 'invalid' : undefined) || (value && props.isDateUnavailable?.(value.end) ? 'invalid' : undefined); - return useBaseDateFieldState({ + return useBaseDateFieldState, RangeValue>({ value, - displayValue, + displayValue: rangeValue, placeholderValue: props.placeholderValue, timeZone, validationState, @@ -258,52 +283,49 @@ export function useRangeDateFieldState(props: RangeDateFieldStateOptions): Range selectedSectionIndexes, selectedSections, isEmpty: - Object.keys(validSegments.start).length === 0 && - Object.keys(validSegments.end).length === 0, - flushAllValidSections, - flushValidSection, + displayValue.start.isCleared(allSegments) && displayValue.end.isCleared(allSegments), setSelectedSections, setValue, - setDate: handleUpdateRange, adjustSection, setSection, getSectionValue, setSectionValue, - createPlaceholder, setValueFromString, + confirmPlaceholder, }); } function useSectionsState( sections: DateFieldSectionWithoutPosition[], - value: RangeValue, - validSegments: RangeValue, + value: RangeValue, + placeholder: RangeValue, delimiter: string, ) { const [state, setState] = React.useState(() => { return { value, sections, - validSegments, + placeholder, delimiter, - editableSections: getRangeEditableSections(sections, value, validSegments, delimiter), + editableSections: getRangeEditableSections(sections, value, placeholder, delimiter), }; }); if ( sections !== state.sections || - validSegments !== state.validSegments || - !(value.start.isSame(state.value.start) && value.end.isSame(state.value.end)) || - value.start.timeZone() !== state.value.start.timeZone() || - value.start.locale() !== state.value.start.locale() || + value !== state.value || + !( + placeholder.start.isSame(state.placeholder.start) && + placeholder.end.isSame(state.placeholder.end) + ) || delimiter !== state.delimiter ) { setState({ value, sections, - validSegments, + placeholder, delimiter, - editableSections: getRangeEditableSections(sections, value, validSegments, delimiter), + editableSections: getRangeEditableSections(sections, value, placeholder, delimiter), }); } diff --git a/src/components/RangeDateField/utils/getRangeEditableSections.ts b/src/components/RangeDateField/utils/getRangeEditableSections.ts index e9e49c2a..ea1387db 100644 --- a/src/components/RangeDateField/utils/getRangeEditableSections.ts +++ b/src/components/RangeDateField/utils/getRangeEditableSections.ts @@ -1,18 +1,18 @@ import type {DateTime} from '@gravity-ui/date-utils'; +import type {IncompleteDate} from '../../DateField/IncompleteDate.js'; import type {DateFieldSectionWithoutPosition} from '../../DateField/types'; -import {getEditableSections, isEditableSection, toEditableSection} from '../../DateField/utils'; -import type {EDITABLE_SEGMENTS} from '../../DateField/utils'; +import {getEditableSections, isEditableSectionType, toEditableSection} from '../../DateField/utils'; import type {RangeValue} from '../../types'; export function getRangeEditableSections( sections: DateFieldSectionWithoutPosition[], - value: RangeValue, - validSegments: RangeValue, + value: RangeValue, + placeholder: RangeValue, delimiter: string, ) { - const start = getEditableSections(sections, value.start, validSegments.start); - const end = getEditableSections(sections, value.end, validSegments.end); + 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; @@ -28,7 +28,7 @@ export function getRangeEditableSections( hasLeadingZeros: false, }, value.start, - validSegments.start, + placeholder.start, position, previousEditableSection, ); @@ -50,7 +50,7 @@ export function getRangeEditableSections( section.nextEditableSection += sectionsCount; - if (isEditableSection(section) && nextEditableSection === undefined) { + if (isEditableSectionType(section.type) && nextEditableSection === undefined) { nextEditableSection = index + sectionsCount; } } diff --git a/src/components/RangeDatePicker/RangeDatePicker.tsx b/src/components/RangeDatePicker/RangeDatePicker.tsx index bdff6804..76e9cc34 100644 --- a/src/components/RangeDatePicker/RangeDatePicker.tsx +++ b/src/components/RangeDatePicker/RangeDatePicker.tsx @@ -73,7 +73,7 @@ export function RangeDatePicker({className, ...props}: RangeDatePickerProps) { name={props.name} form={props.form} onReset={(v) => { - state.dateFieldState.setDate(v); + state.dateFieldState.setValue(v); }} value={state.value} toStringValue={(v) => (v ? v.start.toISOString() : '')}