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() : '')}