diff --git a/src/components/DateField/hooks/useBaseDateFieldState.ts b/src/components/DateField/hooks/useBaseDateFieldState.ts index e9b9c0e..9719d87 100644 --- a/src/components/DateField/hooks/useBaseDateFieldState.ts +++ b/src/components/DateField/hooks/useBaseDateFieldState.ts @@ -4,7 +4,7 @@ import type {DateTime} from '@gravity-ui/date-utils'; import type {ValidationState} from '../../types'; import {createPlaceholderValue} from '../../utils/dates'; -import type {DateFieldSection, DateFieldSectionType} from '../types'; +import type {DateFieldSection, DateFieldSectionType, FormatInfo} from '../types'; import { EDITABLE_SEGMENTS, formatSections, @@ -14,6 +14,7 @@ import { const PAGE_STEP: Partial> = { year: 5, + quarter: 2, month: 2, weekday: 3, day: 7, @@ -29,6 +30,7 @@ export type BaseDateFieldStateOptions = { timeZone: string; validationState?: ValidationState; editableSections: DateFieldSection[]; + formatInfo: FormatInfo; readOnly?: boolean; disabled?: boolean; selectedSectionIndexes: {startIndex: number; endIndex: number} | null; @@ -66,9 +68,17 @@ export type DateFieldState = { disabled?: boolean; /** A list of segments for the current value. */ sections: DateFieldSection[]; - /** Whether the the format is containing date parts */ + /** Some info about available sections */ + formatInfo: FormatInfo; + /** + * @deprecated use formatInfo.hasDate instead. + * Whether the the format is containing date parts + */ hasDate: boolean; - /** Whether the the format is containing time parts */ + /** + * @deprecated use formatInfo.hasTime instead. + * Whether the the format is containing time parts + */ hasTime: boolean; /** Selected sections */ selectedSectionIndexes: {startIndex: number; endIndex: number} | null; @@ -122,6 +132,7 @@ export function useBaseDateFieldState( validationState, displayValue, editableSections, + formatInfo, selectedSectionIndexes, selectedSections, isEmpty, @@ -140,19 +151,6 @@ export function useBaseDateFieldState( const enteredKeys = React.useRef(''); - const {hasDate, hasTime} = React.useMemo(() => { - let hasDateInner = false; - let hasTimeInner = false; - for (const s of editableSections) { - hasTimeInner ||= ['hour', 'minute', 'second'].includes(s.type); - hasDateInner ||= ['day', 'month', 'year'].includes(s.type); - } - return { - hasTime: hasTimeInner, - hasDate: hasDateInner, - }; - }, [editableSections]); - return { value, isEmpty, @@ -163,8 +161,9 @@ export function useBaseDateFieldState( readOnly: props.readOnly, disabled: props.disabled, sections: editableSections, - hasDate, - hasTime, + formatInfo, + hasDate: formatInfo.hasDate, + hasTime: formatInfo.hasTime, selectedSectionIndexes, validationState, setSelectedSections(position) { @@ -425,6 +424,7 @@ export function useBaseDateFieldState( case 'hour': case 'minute': case 'second': + case 'quarter': case 'year': { if (!Number.isInteger(Number(newValue))) { return; diff --git a/src/components/DateField/hooks/useDateFieldState.ts b/src/components/DateField/hooks/useDateFieldState.ts index 2b40ac5..f47e3e6 100644 --- a/src/components/DateField/hooks/useDateFieldState.ts +++ b/src/components/DateField/hooks/useDateFieldState.ts @@ -6,12 +6,18 @@ import {useControlledState} from '@gravity-ui/uikit'; import type {DateFieldBase} from '../../types/datePicker'; import {createPlaceholderValue, isInvalid} from '../../utils/dates'; import {useDefaultTimeZone} from '../../utils/useDefaultTimeZone'; -import type {DateFieldSectionType, DateFieldSectionWithoutPosition} from '../types'; +import type { + AvailableSections, + DateFieldSectionType, + DateFieldSectionWithoutPosition, +} from '../types'; import { - EDITABLE_SEGMENTS, addSegment, + adjustDateToFormat, getEditableSections, + getFormatInfo, isAllSegmentsValid, + markValidSection, parseDateFromString, setSegment, useFormatSections, @@ -47,15 +53,10 @@ export function useDateFieldState(props: DateFieldStateOptions): DateFieldState const format = props.format || 'L'; const sections = useFormatSections(format); - const allSegments: typeof EDITABLE_SEGMENTS = React.useMemo( - () => - sections - .filter((seg) => EDITABLE_SEGMENTS[seg.type]) - .reduce((p, seg) => ({...p, [seg.type]: true}), {}), - [sections], - ); + const formatInfo = React.useMemo(() => getFormatInfo(sections), [sections]); + const allSegments = formatInfo.availableUnits; - const validSegmentsState = React.useState(() => + const validSegmentsState = React.useState(() => value ? {...allSegments} : {}, ); @@ -127,7 +128,7 @@ export function useDateFieldState(props: DateFieldStateOptions): DateFieldState if (isAllSegmentsValid(allSegments, validSegments)) { if (!value || !newValue.isSame(value)) { - handleUpdateDate(newValue); + handleUpdateDate(adjustDateToFormat(newValue, formatInfo)); } } else { if (value) { @@ -138,13 +139,7 @@ export function useDateFieldState(props: DateFieldStateOptions): DateFieldState } function markValid(part: DateFieldSectionType) { - validSegments[part] = true; - if (validSegments.day && validSegments.month && validSegments.year && allSegments.weekday) { - validSegments.weekday = true; - } - if (validSegments.hour && allSegments.dayPeriod) { - validSegments.dayPeriod = true; - } + validSegments = markValidSection(allSegments, validSegments, part); setValidSegments({...validSegments}); } @@ -219,6 +214,7 @@ export function useDateFieldState(props: DateFieldStateOptions): DateFieldState timeZone, validationState, editableSections: sectionsState.editableSections, + formatInfo, readOnly: props.readOnly, disabled: props.disabled, selectedSectionIndexes, @@ -241,7 +237,7 @@ export function useDateFieldState(props: DateFieldStateOptions): DateFieldState function useSectionsState( sections: DateFieldSectionWithoutPosition[], value: DateTime, - validSegments: typeof EDITABLE_SEGMENTS, + validSegments: AvailableSections, ) { const [state, setState] = React.useState(() => { return { diff --git a/src/components/DateField/i18n/en.json b/src/components/DateField/i18n/en.json index 862ac56..ae8402e 100644 --- a/src/components/DateField/i18n/en.json +++ b/src/components/DateField/i18n/en.json @@ -1,5 +1,6 @@ { "year_placeholder": "Y", + "quarter_placeholder": "Q", "month_placeholder": "M", "weekday_placeholder": "E", "day_placeholder": "D", diff --git a/src/components/DateField/i18n/ru.json b/src/components/DateField/i18n/ru.json index 2eb5f09..3b73f9f 100644 --- a/src/components/DateField/i18n/ru.json +++ b/src/components/DateField/i18n/ru.json @@ -1,5 +1,6 @@ { "year_placeholder": "Г", + "quarter_placeholder": "K", "month_placeholder": "М", "weekday_placeholder": "ДН", "day_placeholder": "Д", diff --git a/src/components/DateField/types.ts b/src/components/DateField/types.ts index 34489fc..70ee7f2 100644 --- a/src/components/DateField/types.ts +++ b/src/components/DateField/types.ts @@ -1,17 +1,19 @@ -export type DateFieldSectionType = Extract< - Intl.DateTimeFormatPartTypes, - | 'day' - | 'dayPeriod' - | 'hour' - | 'literal' - | 'minute' - | 'month' - | 'second' - | 'timeZoneName' - | 'weekday' - | 'year' - | 'unknown' ->; +export type DateFieldSectionType = + | Extract< + Intl.DateTimeFormatPartTypes, + | 'day' + | 'dayPeriod' + | 'hour' + | 'literal' + | 'minute' + | 'month' + | 'second' + | 'timeZoneName' + | 'weekday' + | 'year' + | 'unknown' + > + | 'quarter'; export type DateFormatTokenMap = { [formatToken: string]: @@ -91,3 +93,13 @@ export type DateFieldSectionWithoutPosition; + +export type AvailableSections = Partial>; + +export interface FormatInfo { + hasTime: boolean; + hasDate: boolean; + availableUnits: AvailableSections; + minDateUnit: 'day' | 'month' | 'quarter' | 'year'; + minTimeUnit: 'second' | 'minute' | 'hour'; +} diff --git a/src/components/DateField/utils.ts b/src/components/DateField/utils.ts index eda119f..b606973 100644 --- a/src/components/DateField/utils.ts +++ b/src/components/DateField/utils.ts @@ -7,14 +7,17 @@ import {mergeDateTime} from '../utils/dates'; import {i18n} from './i18n'; import type { + AvailableSections, DateFieldSection, DateFieldSectionType, DateFieldSectionWithoutPosition, DateFormatTokenMap, + FormatInfo, } from './types'; -export const EDITABLE_SEGMENTS: Partial> = { +export const EDITABLE_SEGMENTS: AvailableSections = { year: true, + quarter: true, month: true, day: true, hour: true, @@ -31,6 +34,9 @@ const formatTokenMap: DateFormatTokenMap = { YY: 'year', YYYY: 'year', + // Quarter + Q: 'quarter', + // Month M: 'month', MM: 'month', @@ -124,6 +130,9 @@ export function getSectionLimits(section: DateFieldSectionWithoutPosition, date: maxValue: isFourDigit ? 9999 : 99, }; } + case 'quarter': { + return {minValue: 1, maxValue: 4}; + } case 'month': { return { minValue: 0, @@ -174,6 +183,7 @@ export function getSectionValue(section: DateFieldSectionWithoutPosition, date: ? date.year() : Number(date.format(section.format)); } + case 'quarter': case 'month': case 'hour': case 'minute': @@ -232,6 +242,10 @@ export function addSegment(section: DateFieldSection, date: DateTime, amount: nu val = dateTime({input: `${val}`.padStart(2, '0'), format: section.format}).year(); } + if (section.type === 'quarter') { + return date.set(getDurationUnitFromSectionType('month'), val * 3 - 1); + } + const type = getDurationUnitFromSectionType(section.type); return date.set(type, val); } @@ -250,6 +264,9 @@ export function setSegment(section: DateFieldSection, date: DateTime, amount: nu }).year(), ); } + case 'quarter': { + return date.set(getDurationUnitFromSectionType('month'), amount * 3 - 1); + } case 'day': case 'weekday': case 'month': { @@ -308,6 +325,10 @@ function doesSectionHaveLeadingZeros( return formatted2001 === '01'; } + case 'quarter': { + return false; + } + case 'month': { return dateTime().startOf('year').format(format).length > 1; } @@ -347,6 +368,10 @@ function getSectionPlaceholder( return i18n('year_placeholder').repeat(dateTime().format(currentTokenValue).length); } + case 'quarter': { + return i18n('quarter_placeholder'); + } + case 'month': { return i18n('month_placeholder').repeat(sectionConfig.contentType === 'letter' ? 4 : 2); } @@ -512,7 +537,7 @@ export function cleanString(dirtyString: string) { export function getEditableSections( sections: DateFieldSectionWithoutPosition[], value: DateTime, - validSegments: typeof EDITABLE_SEGMENTS, + validSegments: AvailableSections, ) { let position = 1; const newSections: DateFieldSection[] = []; @@ -559,7 +584,7 @@ export function isEditableSection(section: DateFieldSectionWithoutPosition): boo export function toEditableSection( section: DateFieldSectionWithoutPosition, value: DateTime, - validSegments: typeof EDITABLE_SEGMENTS, + validSegments: AvailableSections, position: number, previousEditableSection: number, ): DateFieldSection { @@ -635,12 +660,10 @@ export function parseDateFromString(str: string, format: string, timeZone?: stri } export function isAllSegmentsValid( - allSegments: typeof EDITABLE_SEGMENTS, - validSegments: typeof EDITABLE_SEGMENTS, + allSegments: AvailableSections, + validSegments: AvailableSections, ) { - return Object.keys(allSegments).every( - (key) => validSegments[key as keyof typeof EDITABLE_SEGMENTS], - ); + return Object.keys(allSegments).every((key) => validSegments[key as keyof AvailableSections]); } export function useFormatSections(format: string) { @@ -655,3 +678,77 @@ export function useFormatSections(format: string) { return sections; } + +const dateUnits = ['day', 'month', 'quarter', 'year'] satisfies DateFieldSectionType[]; +const timeUnits = ['second', 'minute', 'hour'] satisfies DateFieldSectionType[]; + +export function getFormatInfo(sections: DateFieldSectionWithoutPosition[]): FormatInfo { + const availableUnits: AvailableSections = {}; + let hasDate = false; + let hasTime = false; + let minDateUnitIndex = dateUnits.length - 1; + let minTimeUnitIndex = timeUnits.length - 1; + for (const s of sections) { + if (!isEditableSection(s)) { + continue; + } + const dateUnitIndex = dateUnits.indexOf(s.type as any); + const timeUnitIndex = timeUnits.indexOf(s.type as any); + availableUnits[s.type] = true; + hasDate ||= dateUnitIndex !== -1; + hasTime ||= timeUnitIndex !== -1; + minDateUnitIndex = + dateUnitIndex === -1 ? minDateUnitIndex : Math.min(dateUnitIndex, minDateUnitIndex); + minTimeUnitIndex = + timeUnitIndex === -1 ? minTimeUnitIndex : Math.min(timeUnitIndex, minTimeUnitIndex); + } + return { + availableUnits, + hasDate, + hasTime, + minDateUnit: dateUnits[minDateUnitIndex] ?? 'day', + minTimeUnit: timeUnits[minTimeUnitIndex] ?? 'second', + }; +} + +export function adjustDateToFormat( + date: DateTime, + formatInfo: FormatInfo, + method: 'startOf' | 'endOf' = 'startOf', +) { + let newDate = date; + if (formatInfo.hasDate) { + if (formatInfo.minDateUnit !== 'day') { + newDate = newDate[method](formatInfo.minDateUnit); + newDate = mergeDateTime(newDate, date); + } + } + if (formatInfo.hasTime) { + newDate = mergeDateTime(newDate, date[method](formatInfo.minTimeUnit)); + } + + 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/DatePicker/DatePicker.tsx b/src/components/DatePicker/DatePicker.tsx index c7cd763..6d7b6a7 100644 --- a/src/components/DatePicker/DatePicker.tsx +++ b/src/components/DatePicker/DatePicker.tsx @@ -52,7 +52,7 @@ export function DatePicker({className, ...props}: DatePickerProps) { useDatePickerProps(state, props); const isMobile = useMobile(); - const isOnlyTime = state.hasTime && !state.hasDate; + const isOnlyTime = state.formatInfo.hasTime && !state.formatInfo.hasDate; return (
@@ -68,7 +68,7 @@ export function DatePicker({className, ...props}: DatePickerProps) { ) : ( )} - {state.hasTime && ( + {state.formatInfo.hasTime && (
diff --git a/src/components/DatePicker/MobileCalendar.tsx b/src/components/DatePicker/MobileCalendar.tsx index 2609a34..14c3eec 100644 --- a/src/components/DatePicker/MobileCalendar.tsx +++ b/src/components/DatePicker/MobileCalendar.tsx @@ -21,9 +21,9 @@ interface MobileCalendarProps { } export function MobileCalendar({props, state}: MobileCalendarProps) { let type: InputDateType = 'date'; - if (state.hasTime && state.hasDate) { + if (state.formatInfo.hasTime && state.formatInfo.hasDate) { type = 'datetime-local'; - } else if (state.hasTime) { + } else if (state.formatInfo.hasTime) { type = 'time'; } @@ -48,13 +48,13 @@ export function MobileCalendar({props, state}: MobileCalendarProps) { format: getDateFormat(type), timeZone: 'system', }).timeZone(state.timeZone, true); - let newDate = state.hasDate + let newDate = state.formatInfo.hasDate ? localDate : createPlaceholderValue({ placeholderValue: props.placeholderValue?.timeZone(state.timeZone), timeZone: state.timeZone, }); - if (state.hasTime) { + if (state.formatInfo.hasTime) { newDate = mergeDateTime(newDate, localDate); } else if (state.value) { newDate = mergeDateTime(newDate, state.value.timeZone(state.timeZone)); diff --git a/src/components/DatePicker/hooks/datePickerStateFactory.ts b/src/components/DatePicker/hooks/datePickerStateFactory.ts index ada1715..9b21849 100644 --- a/src/components/DatePicker/hooks/datePickerStateFactory.ts +++ b/src/components/DatePicker/hooks/datePickerStateFactory.ts @@ -4,6 +4,7 @@ import type {DateTime} from '@gravity-ui/date-utils'; import {useControlledState} from '@gravity-ui/uikit'; import type {DateFieldState} from '../../DateField'; +import type {FormatInfo} from '../../DateField/types'; import type {DateFieldBase, PopupTriggerProps} from '../../types'; import {useDefaultTimeZone} from '../../utils/useDefaultTimeZone'; @@ -40,9 +41,17 @@ export interface DatePickerState { disabled?: boolean; /** Format of the date when rendered in the input. */ format: string; - /** Whether the date picker supports selecting a date. */ + /** Format info */ + formatInfo: FormatInfo; + /** + * @deprecated use formatInfo.hasDate instead + * Whether the date picker supports selecting a date. + */ hasDate: boolean; - /** Whether the date picker supports selecting a time. */ + /** + * @deprecated use formatInfo.hasTime instead + * Whether the date picker supports selecting a time. + */ hasTime: boolean; /** Format of the time when rendered in the input. */ timeFormat?: string; @@ -60,6 +69,7 @@ export interface DatePickerStateFactoryOptions> { setTimezone: (date: T, timeZone: string) => T; getDateTime: (date: T | null | undefined) => DateTime | undefined; useDateFieldState: (props: O) => DateFieldState; + adjustDateToFormat: (date: T, info: FormatInfo) => T; } export interface DatePickerStateOptions @@ -72,6 +82,7 @@ export function datePickerStateFactory>({ setTimezone, getDateTime, useDateFieldState, + adjustDateToFormat, }: DatePickerStateFactoryOptions) { return function useDatePickerState(props: O): DatePickerState { const {disabled, readOnly} = props; @@ -133,7 +144,7 @@ export function datePickerStateFactory>({ }); const timeFormat = React.useMemo(() => { - if (!dateFieldState.hasTime) { + if (!dateFieldState.formatInfo.hasTime) { return undefined; } const timeFormatParts: string[] = []; @@ -151,12 +162,12 @@ export function datePickerStateFactory>({ } const dayPeriod = dateFieldState.sections.find((s) => s.type === 'dayPeriod'); return timeFormatParts.join(':') + (dayPeriod ? ` ${dayPeriod.format}` : ''); - }, [dateFieldState.hasTime, dateFieldState.sections]); + }, [dateFieldState.formatInfo.hasTime, dateFieldState.sections]); if (value) { selectedDate = setTimezone(value, timeZone); - if (dateFieldState.hasTime) { - selectedTime = setTimezone(value, timeZone); + if (dateFieldState.formatInfo.hasTime) { + selectedTime = selectedDate; } } @@ -166,15 +177,16 @@ export function datePickerStateFactory>({ return; } - const shouldClose = !dateFieldState.hasTime; - if (dateFieldState.hasTime) { + const newDate = adjustDateToFormat(newValue, dateFieldState.formatInfo); + const shouldClose = !dateFieldState.formatInfo.hasTime; + if (dateFieldState.formatInfo.hasTime) { if (selectedTime || shouldClose) { - commitValue(newValue, selectedTime || newValue); + commitValue(newDate, selectedTime || newDate); } else { - setSelectedDate(newValue); + setSelectedDate(newDate); } } else { - commitValue(newValue, newValue); + commitValue(newDate, newDate); } if (shouldClose) { @@ -187,7 +199,10 @@ export function datePickerStateFactory>({ return; } - const newTime = newValue ?? getPlaceholderTime(props.placeholderValue, timeZone); + const newTime = adjustDateToFormat( + newValue ?? getPlaceholderTime(props.placeholderValue, timeZone), + dateFieldState.formatInfo, + ); if (selectedDate) { commitValue(selectedDate, newTime); @@ -196,7 +211,7 @@ export function datePickerStateFactory>({ } }; - if (dateFieldState.hasTime && !selectedTime) { + if (dateFieldState.formatInfo.hasTime && !selectedTime) { selectedTime = dateFieldState.displayValue; } @@ -220,13 +235,14 @@ export function datePickerStateFactory>({ disabled, readOnly, format, - hasDate: dateFieldState.hasDate, - hasTime: dateFieldState.hasTime, + formatInfo: dateFieldState.formatInfo, + hasDate: dateFieldState.formatInfo.hasDate, + hasTime: dateFieldState.formatInfo.hasTime, timeFormat, timeZone, isOpen, setOpen(newIsOpen, reason) { - if (!newIsOpen && !value && selectedDate && dateFieldState.hasTime) { + if (!newIsOpen && !value && selectedDate && dateFieldState.formatInfo.hasTime) { commitValue( selectedDate, selectedTime || getPlaceholderTime(props.placeholderValue, props.timeZone), diff --git a/src/components/DatePicker/hooks/useDatePickerProps.ts b/src/components/DatePicker/hooks/useDatePickerProps.ts index abfdff3..2d5334f 100644 --- a/src/components/DatePicker/hooks/useDatePickerProps.ts +++ b/src/components/DatePicker/hooks/useDatePickerProps.ts @@ -13,7 +13,7 @@ import {getButtonSizeForInput} from '../../utils/getButtonSizeForInput'; import {mergeProps} from '../../utils/mergeProps'; import type {DatePickerProps} from '../DatePicker'; import {i18n} from '../i18n'; -import {getDateTimeValue} from '../utils'; +import {getCalendarModes, getDateTimeValue} from '../utils'; import type {DatePickerState} from './useDatePickerState'; @@ -82,7 +82,8 @@ export function useDatePickerProps>( }); } - const onlyTime = state.hasTime && !state.hasDate; + const onlyTime = state.formatInfo.hasTime && !state.formatInfo.hasDate; + const calendarModes = getCalendarModes(state.formatInfo); return { groupProps: { @@ -157,7 +158,7 @@ export function useDatePickerProps>( readOnly: props.readOnly, onUpdate: (d) => { state.setDateValue(d); - if (!state.hasTime) { + if (!state.formatInfo.hasTime) { focusInput(); } }, @@ -168,6 +169,7 @@ export function useDatePickerProps>( timeZone: state.timeZone, focusedValue: focusedDate, onFocusUpdate: setFocusedDate, + modes: calendarModes, }, timeInputProps: { value: state.timeValue, diff --git a/src/components/DatePicker/hooks/useDatePickerState.ts b/src/components/DatePicker/hooks/useDatePickerState.ts index 00f3f06..aa92662 100644 --- a/src/components/DatePicker/hooks/useDatePickerState.ts +++ b/src/components/DatePicker/hooks/useDatePickerState.ts @@ -1,6 +1,7 @@ import type {DateTime} from '@gravity-ui/date-utils'; import {useDateFieldState} from '../../DateField'; +import {adjustDateToFormat} from '../../DateField/utils'; import type {DateFieldBase} from '../../types'; import {createPlaceholderValue, mergeDateTime} from '../../utils/dates'; import {getDateTimeValue} from '../utils'; @@ -17,6 +18,7 @@ export const useDatePickerState = datePickerStateFactory({ setTimezone: (date, timeZone) => date.timeZone(timeZone), getDateTime: getDateTimeValue, useDateFieldState, + adjustDateToFormat, }); function getPlaceholderTime(placeholderValue: DateTime | undefined, timeZone?: string) { diff --git a/src/components/DatePicker/utils/getCalendarModes.ts b/src/components/DatePicker/utils/getCalendarModes.ts new file mode 100644 index 0000000..e46ecdf --- /dev/null +++ b/src/components/DatePicker/utils/getCalendarModes.ts @@ -0,0 +1,26 @@ +import type {CalendarLayout} from '../../CalendarView/hooks/types'; +import type {FormatInfo} from '../../DateField/types'; + +type LayoutModes = Partial>; + +export function getCalendarModes(formatInfo: FormatInfo): LayoutModes | undefined { + if (!formatInfo.hasDate) { + return undefined; + } + + const modes: LayoutModes = {years: true}; + if (formatInfo.availableUnits.day) { + modes.days = true; + modes.months = true; + } + + if (formatInfo.availableUnits.month) { + modes.months = true; + } + + if (formatInfo.availableUnits.quarter && !modes.months) { + modes.quarters = true; + } + + return modes; +} diff --git a/src/components/DatePicker/utils/index.ts b/src/components/DatePicker/utils/index.ts index 9e3e0f5..f9405a1 100644 --- a/src/components/DatePicker/utils/index.ts +++ b/src/components/DatePicker/utils/index.ts @@ -1,2 +1,3 @@ export * from './cn'; export * from './getDateTimeValue'; +export * from './getCalendarModes'; diff --git a/src/components/RangeDateField/hooks/useRangeDateFieldState.test.ts b/src/components/RangeDateField/hooks/useRangeDateFieldState.test.ts index b3ed948..36014b1 100644 --- a/src/components/RangeDateField/hooks/useRangeDateFieldState.test.ts +++ b/src/components/RangeDateField/hooks/useRangeDateFieldState.test.ts @@ -85,8 +85,8 @@ test('call onUpdate only if the entire value is valid', () => { expect(cleanString(result.current.text)).toBe('31.01.2024 — 29.02.2024'); expect(onUpdateSpy).toHaveBeenLastCalledWith({ - start: dateTime({input: '2024-01-31T00:00:00', timeZone}), - end: dateTime({input: '2024-02-29T00:00:00', timeZone}), + start: dateTime({input: '2024-01-31T00:00:00', timeZone}).startOf('day'), + end: dateTime({input: '2024-02-29T00:00:00', timeZone}).endOf('day'), }); }); diff --git a/src/components/RangeDateField/hooks/useRangeDateFieldState.ts b/src/components/RangeDateField/hooks/useRangeDateFieldState.ts index ed473b1..540f482 100644 --- a/src/components/RangeDateField/hooks/useRangeDateFieldState.ts +++ b/src/components/RangeDateField/hooks/useRangeDateFieldState.ts @@ -10,7 +10,10 @@ import type {DateFieldSectionType, DateFieldSectionWithoutPosition} from '../../ import { EDITABLE_SEGMENTS, addSegment, + adjustDateToFormat, + getFormatInfo, isAllSegmentsValid, + markValidSection, parseDateFromString, setSegment, useFormatSections, @@ -57,12 +60,9 @@ export function useRangeDateFieldState(props: RangeDateFieldStateOptions): Range 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: typeof EDITABLE_SEGMENTS = React.useMemo(() => { - return sections - .filter((seg) => EDITABLE_SEGMENTS[seg.type]) - .reduce((p, seg) => ({...p, [seg.type]: true}), {}); - }, [sections]); + const allSegments = formatInfo.availableUnits; // eslint-disable-next-line prefer-const let [validSegments, setValidSegments] = React.useState>( @@ -150,7 +150,10 @@ export function useRangeDateFieldState(props: RangeDateFieldStateOptions): Range isAllSegmentsValid(allSegments, validSegments.end) ) { if (!value || !(newValue.start.isSame(value.start) && newValue.end.isSame(value.end))) { - handleUpdateRange(newValue); + handleUpdateRange({ + start: adjustDateToFormat(newValue.start, formatInfo, 'startOf'), + end: adjustDateToFormat(newValue.end, formatInfo, 'endOf'), + }); } } else { if (value) { @@ -161,18 +164,7 @@ export function useRangeDateFieldState(props: RangeDateFieldStateOptions): Range } function markValid(portion: 'start' | 'end', part: DateFieldSectionType) { - validSegments[portion][part] = true; - if ( - validSegments[portion].day && - validSegments[portion].month && - validSegments[portion].year && - allSegments.weekday - ) { - validSegments[portion].weekday = true; - } - if (validSegments[portion].hour && allSegments.dayPeriod) { - validSegments[portion].dayPeriod = true; - } + validSegments[portion] = markValidSection(allSegments, validSegments[portion], part); setValidSegments({...validSegments, [portion]: {...validSegments[portion]}}); } @@ -257,6 +249,7 @@ export function useRangeDateFieldState(props: RangeDateFieldStateOptions): Range timeZone, validationState, editableSections: sectionsState.editableSections, + formatInfo, readOnly: props.readOnly, disabled: props.disabled, selectedSectionIndexes, diff --git a/src/components/RangeDateField/utils/createPlaceholderRangeValue.ts b/src/components/RangeDateField/utils/createPlaceholderRangeValue.ts index 66516f8..2f1c1fa 100644 --- a/src/components/RangeDateField/utils/createPlaceholderRangeValue.ts +++ b/src/components/RangeDateField/utils/createPlaceholderRangeValue.ts @@ -3,5 +3,5 @@ import type {PlaceholderValueOptions} from '../../utils/dates'; export function createPlaceholderRangeValue({placeholderValue, timeZone}: PlaceholderValueOptions) { const date = createPlaceholderValue({placeholderValue, timeZone}); - return {start: date, end: date}; + return {start: date.startOf('day'), end: date.endOf('day')}; } diff --git a/src/components/RangeDatePicker/RangeDatePicker.tsx b/src/components/RangeDatePicker/RangeDatePicker.tsx index 559a1bf..f3f8b36 100644 --- a/src/components/RangeDatePicker/RangeDatePicker.tsx +++ b/src/components/RangeDatePicker/RangeDatePicker.tsx @@ -31,7 +31,7 @@ export function RangeDatePicker({className, ...props}: RangeDatePickerProps) { const {groupProps, fieldProps, calendarButtonProps, popupProps, calendarProps, timeInputProps} = useDatePickerProps(state, props); - const isOnlyTime = state.hasTime && !state.hasDate; + const isOnlyTime = state.formatInfo.hasTime && !state.formatInfo.hasDate; return (
@@ -44,7 +44,7 @@ export function RangeDatePicker({className, ...props}: RangeDatePickerProps) { ) : ( )} - {state.hasTime && ( + {state.formatInfo.hasTime && (
diff --git a/src/components/RangeDatePicker/hooks/useRangeDatePickerState.ts b/src/components/RangeDatePicker/hooks/useRangeDatePickerState.ts index 10c4589..24b9f66 100644 --- a/src/components/RangeDatePicker/hooks/useRangeDatePickerState.ts +++ b/src/components/RangeDatePicker/hooks/useRangeDatePickerState.ts @@ -1,12 +1,15 @@ import type {DateTime} from '@gravity-ui/date-utils'; +import type {FormatInfo} from '../../DateField/types'; +import {adjustDateToFormat} from '../../DateField/utils'; import type {DatePickerState} from '../../DatePicker'; import {datePickerStateFactory} from '../../DatePicker/hooks/datePickerStateFactory'; import {getDateTimeValue} from '../../DatePicker/utils'; import {useRangeDateFieldState} from '../../RangeDateField'; import type {RangeDateFieldStateOptions} from '../../RangeDateField'; +import {createPlaceholderRangeValue} from '../../RangeDateField/utils'; import type {RangeValue} from '../../types'; -import {createPlaceholderValue, mergeDateTime} from '../../utils/dates'; +import {mergeDateTime} from '../../utils/dates'; export type RangeDatePickerState = DatePickerState>; @@ -18,14 +21,14 @@ export const useRangeDatePickerState = datePickerStateFactory({ setTimezone, getDateTime: getDateTimeValue, useDateFieldState: useRangeDateFieldState, + adjustDateToFormat: adjustRangeToFormat, }); function getPlaceholderTime( placeholderValue: DateTime | undefined, timeZone?: string, ): RangeValue { - const date = createPlaceholderValue({placeholderValue, timeZone}); - return {start: date, end: date}; + return createPlaceholderRangeValue({placeholderValue, timeZone}); } function mergeRangeDateTime( @@ -42,3 +45,9 @@ function setTimezone(date: RangeValue, timeZone: string): RangeValue, sectionsInfo: FormatInfo) { + const start = adjustDateToFormat(date.start, sectionsInfo, 'startOf'); + const end = adjustDateToFormat(date.end, sectionsInfo, 'endOf'); + return {start, end}; +} diff --git a/src/components/RelativeDatePicker/RelativeDatePicker.tsx b/src/components/RelativeDatePicker/RelativeDatePicker.tsx index 4b55a2e..13c1cac 100644 --- a/src/components/RelativeDatePicker/RelativeDatePicker.tsx +++ b/src/components/RelativeDatePicker/RelativeDatePicker.tsx @@ -65,7 +65,8 @@ export function RelativeDatePicker(props: RelativeDatePickerProps) { const handleRef = useForkRef(anchorRef, groupProps.ref); const isMobile = useMobile(); - const isOnlyTime = state.datePickerState.hasTime && !state.datePickerState.hasDate; + const isOnlyTime = + state.datePickerState.formatInfo.hasTime && !state.datePickerState.formatInfo.hasDate; return (
@@ -137,7 +138,7 @@ export function RelativeDatePicker(props: RelativeDatePickerProps) { ) : ( )} - {state.datePickerState.hasTime && ( + {state.datePickerState.formatInfo.hasTime && (
diff --git a/src/components/RelativeDatePicker/hooks/useRelativeDatePickerProps.ts b/src/components/RelativeDatePicker/hooks/useRelativeDatePickerProps.ts index 80b3a95..a9c8bc2 100644 --- a/src/components/RelativeDatePicker/hooks/useRelativeDatePickerProps.ts +++ b/src/components/RelativeDatePicker/hooks/useRelativeDatePickerProps.ts @@ -6,6 +6,7 @@ import type {ButtonProps, PopupProps, TextInputProps} from '@gravity-ui/uikit'; import type {Calendar, CalendarInstance} from '../../Calendar'; import {useDateFieldProps} from '../../DateField'; import type {DateFieldProps} from '../../DateField'; +import {getCalendarModes} from '../../DatePicker/utils'; import {useRelativeDateFieldProps} from '../../RelativeDateField'; import {getButtonSizeForInput} from '../../utils/getButtonSizeForInput'; import {mergeProps} from '../../utils/mergeProps'; @@ -131,6 +132,7 @@ export function useRelativeDatePickerProps( }); } const groupRef = React.useRef(null); + const calendarModes = getCalendarModes(datePickerState.formatInfo); return { groupProps: { @@ -223,7 +225,7 @@ export function useRelativeDatePickerProps( value: state.selectedDate, onUpdate: (v) => { datePickerState.setDateValue(v); - if (!state.datePickerState.hasTime) { + if (!state.datePickerState.formatInfo.hasTime) { setOpen(false); focusInput(); } @@ -232,6 +234,7 @@ export function useRelativeDatePickerProps( onFocusUpdate: setFocusedDate, minValue: props.minValue, maxValue: props.maxValue, + modes: mode === 'absolute' ? calendarModes : undefined, }, timeInputProps: { value: datePickerState.timeValue, diff --git a/src/components/RelativeDatePicker/hooks/useRelativeDatePickerState.ts b/src/components/RelativeDatePicker/hooks/useRelativeDatePickerState.ts index ec9899c..b2f5318 100644 --- a/src/components/RelativeDatePicker/hooks/useRelativeDatePickerState.ts +++ b/src/components/RelativeDatePicker/hooks/useRelativeDatePickerState.ts @@ -3,6 +3,7 @@ import React from 'react'; import type {DateTime} from '@gravity-ui/date-utils'; import {useControlledState} from '@gravity-ui/uikit'; +import {adjustDateToFormat} from '../../DateField/utils'; import {useDatePickerState} from '../../DatePicker'; import type {DatePickerState} from '../../DatePicker'; import {useRelativeDateFieldState} from '../../RelativeDateField'; @@ -78,13 +79,20 @@ export function useRelativeDatePickerState( const datePickerState = useDatePickerState({ value: valueDate, onUpdate: (date) => { - setValueDate(date); + let newDate = date; + if (newDate && props.roundUp) { + newDate = adjustDateToFormat(newDate, datePickerState.formatInfo, 'endOf'); + if (!datePickerState.formatInfo.hasTime) { + newDate = newDate.endOf('day'); + } + } + setValueDate(newDate); - if (value?.type === 'absolute' && date?.isSame(value.value)) { + if (value?.type === 'absolute' && newDate?.isSame(value.value)) { return; } - setValue(date ? {type: 'absolute', value: date} : null); + setValue(newDate ? {type: 'absolute', value: newDate} : null); }, format: props.format, placeholderValue: props.placeholderValue, diff --git a/src/components/utils/dates.ts b/src/components/utils/dates.ts index f63c996..bd9d07c 100644 --- a/src/components/utils/dates.ts +++ b/src/components/utils/dates.ts @@ -10,9 +10,7 @@ export interface PlaceholderValueOptions { timeZone?: string; } export function createPlaceholderValue({placeholderValue, timeZone}: PlaceholderValueOptions) { - return ( - placeholderValue ?? dateTime({timeZone}).set('hour', 0).set('minute', 0).set('second', 0) - ); + return placeholderValue ?? dateTime({timeZone}).startOf('day'); } export function isInvalid(