Skip to content

Commit ce19b43

Browse files
krozhkovKonstantin Rozhkov
andauthored
feat: add a new range date picker component (#47)
Co-authored-by: Konstantin Rozhkov <[email protected]>
1 parent 239530f commit ce19b43

21 files changed

+534
-232
lines changed

src/components/CalendarView/hooks/useCalendarState.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type {CalendarLayout, CalendarState, CalendarStateOptionsBase} from './ty
1414
export interface CalendarStateOptions<T = DateTime>
1515
extends ValueBase<T | null, T>,
1616
CalendarStateOptionsBase {}
17+
1718
export type {CalendarState} from './types';
1819

1920
const defaultModes: Record<CalendarLayout, boolean> = {

src/components/DateField/hooks/useBaseDateFieldState.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ export type BaseDateFieldStateOptions<T = DateTime> = {
4747
setValueFromString: (str: string) => boolean;
4848
};
4949

50-
export type BaseDateFieldState<T = DateTime> = {
50+
export type DateFieldState<T = DateTime> = {
5151
/** The current field value. */
5252
value: T | null;
5353
/** Is no part of value is filled. */
@@ -114,7 +114,7 @@ export type BaseDateFieldState<T = DateTime> = {
114114

115115
export function useBaseDateFieldState<T = DateTime>(
116116
props: BaseDateFieldStateOptions<T>,
117-
): BaseDateFieldState<T> {
117+
): DateFieldState<T> {
118118
const {
119119
value,
120120
validationState,

src/components/DateField/hooks/useDateFieldProps.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import type {
1616
} from '../../types';
1717
import {cleanString} from '../utils';
1818

19-
import type {BaseDateFieldState} from './useBaseDateFieldState';
19+
import type {DateFieldState} from './useBaseDateFieldState';
2020

2121
export interface DateFieldProps<T = DateTime>
2222
extends DateFieldBase<T>,
@@ -30,7 +30,7 @@ export interface DateFieldProps<T = DateTime>
3030
AccessibilityProps {}
3131

3232
export function useDateFieldProps<T = DateTime>(
33-
state: BaseDateFieldState<T>,
33+
state: DateFieldState<T>,
3434
props: DateFieldProps<T>,
3535
): {inputProps: TextInputProps} {
3636
const inputRef = React.useRef<HTMLInputElement>(null);

src/components/DateField/hooks/useDateFieldState.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,10 @@ import {
1919
} from '../utils';
2020

2121
import {useBaseDateFieldState} from './useBaseDateFieldState';
22-
import type {BaseDateFieldState} from './useBaseDateFieldState';
22+
import type {DateFieldState} from './useBaseDateFieldState';
2323

2424
export interface DateFieldStateOptions extends DateFieldBase {}
2525

26-
export type DateFieldState = BaseDateFieldState;
27-
2826
export function useDateFieldState(props: DateFieldStateOptions): DateFieldState {
2927
const [value, setDate] = useControlledState(
3028
props.value,

src/components/DateField/utils.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ function getDateSectionConfigFromFormatToken(formatToken: string): {
105105
const config = formatTokenMap[formatToken];
106106

107107
if (!config) {
108+
// eslint-disable-next-line no-console
108109
console.error(
109110
[
110111
`The token "${formatToken}" is not supported by the Date field.`,

src/components/DatePicker/DatePicker.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React from 'react';
22

3+
import type {DateTime} from '@gravity-ui/date-utils';
34
import {Calendar as CalendarIcon, Clock as ClockIcon} from '@gravity-ui/icons';
45
import {Button, Icon, Popup, TextInput, useMobile} from '@gravity-ui/uikit';
56

@@ -25,16 +26,16 @@ import {b} from './utils';
2526

2627
import './DatePicker.scss';
2728

28-
export interface DatePickerProps
29-
extends DateFieldBase,
29+
export interface DatePickerProps<T = DateTime>
30+
extends DateFieldBase<T>,
3031
TextInputProps,
3132
FocusableProps,
3233
KeyboardEvents,
3334
DomProps,
3435
InputDOMProps,
3536
StyleProps,
3637
AccessibilityProps {
37-
children?: (props: CalendarProps) => React.ReactNode;
38+
children?: (props: CalendarProps<T>) => React.ReactNode;
3839
}
3940

4041
export function DatePicker({className, ...props}: DatePickerProps) {
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
import React from 'react';
2+
3+
import type {DateTime} from '@gravity-ui/date-utils';
4+
import {useControlledState} from '@gravity-ui/uikit';
5+
6+
import type {DateFieldState} from '../../DateField';
7+
import type {DateFieldBase} from '../../types';
8+
import {useDefaultTimeZone} from '../../utils/useDefaultTimeZone';
9+
10+
export interface DatePickerState<T = DateTime> {
11+
/** The currently selected date. */
12+
value: T | null;
13+
/** Sets the selected date. */
14+
setValue: (value: T | null) => void;
15+
/**
16+
* The date portion of the value. This may be set prior to `value` if the user has
17+
* selected a date but has not yet selected a time.
18+
*/
19+
dateValue: T | null;
20+
/** Sets the date portion of the value. */
21+
setDateValue: (value: T) => void;
22+
/**
23+
* The time portion of the value. This may be set prior to `value` if the user has
24+
* selected a time but has not yet selected a date.
25+
*/
26+
timeValue: T | null;
27+
/** Sets the time portion of the value. */
28+
setTimeValue: (value: T | null) => void;
29+
/** Whether the field is read only. */
30+
readOnly?: boolean;
31+
/** Whether the field is disabled. */
32+
disabled?: boolean;
33+
/** Format of the date when rendered in the input. */
34+
format: string;
35+
/** Whether the date picker supports selecting a date. */
36+
hasDate: boolean;
37+
/** Whether the date picker supports selecting a time. */
38+
hasTime: boolean;
39+
/** Format of the time when rendered in the input. */
40+
timeFormat?: string;
41+
timeZone: string;
42+
/** Whether the calendar popover is currently open. */
43+
isOpen: boolean;
44+
/** Sets whether the calendar popover is open. */
45+
setOpen: (isOpen: boolean) => void;
46+
dateFieldState: DateFieldState<T>;
47+
}
48+
49+
export interface DatePickerStateFactoryOptions<T, O extends DateFieldBase<T>> {
50+
getPlaceholderTime: (placeholderValue: DateTime | undefined, timeZone?: string) => T;
51+
mergeDateTime: (date: T, time: T) => T;
52+
setTimezone: (date: T, timeZone: string) => T;
53+
getDateTime: (date: T | null | undefined) => DateTime | undefined;
54+
useDateFieldState: (props: O) => DateFieldState<T>;
55+
}
56+
57+
export function datePickerStateFactory<T, O extends DateFieldBase<T>>({
58+
getPlaceholderTime,
59+
mergeDateTime,
60+
setTimezone,
61+
getDateTime,
62+
useDateFieldState,
63+
}: DatePickerStateFactoryOptions<T, O>) {
64+
return function useDatePickerState(props: O): DatePickerState<T> {
65+
const {disabled, readOnly} = props;
66+
const [isOpen, setOpen] = React.useState(false);
67+
68+
const [value, setValue] = useControlledState(
69+
props.value as never,
70+
props.defaultValue ?? null,
71+
props.onUpdate,
72+
);
73+
const [selectedDateInner, setSelectedDate] = React.useState<T | null>(null);
74+
const [selectedTimeInner, setSelectedTime] = React.useState<T | null>(null);
75+
76+
const inputTimeZone = useDefaultTimeZone(
77+
getDateTime(props.value) || getDateTime(props.defaultValue) || props.placeholderValue,
78+
);
79+
const timeZone = props.timeZone || inputTimeZone;
80+
81+
let selectedDate = selectedDateInner;
82+
let selectedTime = selectedTimeInner;
83+
84+
const format = props.format || 'L';
85+
86+
const commitValue = (date: T, time: T) => {
87+
if (disabled || readOnly) {
88+
return;
89+
}
90+
91+
setValue(setTimezone(mergeDateTime(date, time), inputTimeZone));
92+
setSelectedDate(null);
93+
setSelectedTime(null);
94+
};
95+
96+
const dateFieldState = useDateFieldState({
97+
...props,
98+
value,
99+
onUpdate(date: T | null) {
100+
if (date) {
101+
commitValue(date, date);
102+
} else {
103+
setValue(null);
104+
}
105+
},
106+
disabled,
107+
readOnly,
108+
validationState: props.validationState,
109+
minValue: props.minValue,
110+
maxValue: props.maxValue,
111+
isDateUnavailable: props.isDateUnavailable,
112+
format,
113+
placeholderValue: props.placeholderValue,
114+
timeZone,
115+
});
116+
117+
const timeFormat = React.useMemo(() => {
118+
const hasSeconds = dateFieldState.sections.some((s) => s.type === 'second');
119+
return hasSeconds ? 'LTS' : 'LT';
120+
}, [dateFieldState.sections]);
121+
122+
if (value) {
123+
selectedDate = setTimezone(value, timeZone);
124+
if (dateFieldState.hasTime) {
125+
selectedTime = setTimezone(value, timeZone);
126+
}
127+
}
128+
129+
// Intercept setValue to make sure the Time section is not changed by date selection in Calendar
130+
const selectDate = (newValue: T) => {
131+
if (disabled || readOnly) {
132+
return;
133+
}
134+
135+
const shouldClose = !dateFieldState.hasTime;
136+
if (dateFieldState.hasTime) {
137+
if (selectedTime || shouldClose) {
138+
commitValue(newValue, selectedTime || newValue);
139+
} else {
140+
setSelectedDate(newValue);
141+
}
142+
} else {
143+
commitValue(newValue, newValue);
144+
}
145+
146+
if (shouldClose) {
147+
setOpen(false);
148+
}
149+
};
150+
151+
const selectTime = (newValue: T | null) => {
152+
if (disabled || readOnly) {
153+
return;
154+
}
155+
156+
const newTime = newValue ?? getPlaceholderTime(props.placeholderValue, timeZone);
157+
158+
if (selectedDate) {
159+
commitValue(selectedDate, newTime);
160+
} else {
161+
setSelectedTime(newTime);
162+
}
163+
};
164+
165+
if (dateFieldState.hasTime && !selectedTime) {
166+
selectedTime = dateFieldState.displayValue;
167+
}
168+
169+
return {
170+
value,
171+
setValue(newDate: T | null) {
172+
if (props.readOnly || props.disabled) {
173+
return;
174+
}
175+
176+
if (newDate) {
177+
setValue(setTimezone(newDate, inputTimeZone));
178+
} else {
179+
setValue(null);
180+
}
181+
},
182+
dateValue: selectedDate,
183+
timeValue: selectedTime,
184+
setDateValue: selectDate,
185+
setTimeValue: selectTime,
186+
disabled,
187+
readOnly,
188+
format,
189+
hasDate: dateFieldState.hasDate,
190+
hasTime: dateFieldState.hasTime,
191+
timeFormat,
192+
timeZone,
193+
isOpen,
194+
setOpen(newIsOpen) {
195+
if (!newIsOpen && !value && selectedDate && dateFieldState.hasTime) {
196+
commitValue(
197+
selectedDate,
198+
selectedTime || getPlaceholderTime(props.placeholderValue, props.timeZone),
199+
);
200+
}
201+
202+
setOpen(newIsOpen);
203+
},
204+
dateFieldState,
205+
};
206+
};
207+
}

src/components/DatePicker/hooks/useDatePickerProps.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,35 @@
11
import React from 'react';
22

3+
import type {DateTime} from '@gravity-ui/date-utils';
34
import {useFocusWithin, useForkRef} from '@gravity-ui/uikit';
45
import type {ButtonProps, PopupProps, TextInputProps} from '@gravity-ui/uikit';
56

6-
import type {Calendar, CalendarInstance} from '../../Calendar';
7+
import type {CalendarInstance, CalendarProps} from '../../Calendar';
78
import {useDateFieldProps} from '../../DateField';
89
import type {DateFieldProps} from '../../DateField';
10+
import type {RangeValue} from '../../types';
911
import {getButtonSizeForInput} from '../../utils/getButtonSizeForInput';
1012
import {mergeProps} from '../../utils/mergeProps';
1113
import type {DatePickerProps} from '../DatePicker';
1214
import {i18n} from '../i18n';
15+
import {getDateTimeValue} from '../utils';
1316

1417
import type {DatePickerState} from './useDatePickerState';
1518

16-
interface InnerRelativeDatePickerProps {
19+
interface InnerDatePickerProps<T = DateTime> {
20+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
1721
groupProps: React.HTMLAttributes<unknown> & {ref: React.Ref<any>};
1822
fieldProps: TextInputProps;
1923
calendarButtonProps: ButtonProps & {ref: React.Ref<HTMLButtonElement>};
2024
popupProps: PopupProps;
21-
calendarProps: React.ComponentProps<typeof Calendar>;
22-
timeInputProps: DateFieldProps;
25+
calendarProps: CalendarProps<T> & {ref: React.Ref<CalendarInstance>};
26+
timeInputProps: DateFieldProps<T>;
2327
}
2428

25-
export function useDatePickerProps(
26-
state: DatePickerState,
27-
{onFocus, onBlur, ...props}: DatePickerProps,
28-
): InnerRelativeDatePickerProps {
29+
export function useDatePickerProps<T extends DateTime | RangeValue<DateTime>>(
30+
state: DatePickerState<T>,
31+
{onFocus, onBlur, ...props}: DatePickerProps<T>,
32+
): InnerDatePickerProps<T> {
2933
const [isActive, setActive] = React.useState(false);
3034

3135
const {focusWithinProps} = useFocusWithin({
@@ -131,7 +135,7 @@ export function useDatePickerProps(
131135
},
132136
timeInputProps: {
133137
value: state.timeValue,
134-
placeholderValue: state.dateFieldState.displayValue,
138+
placeholderValue: getDateTimeValue(state.dateFieldState.displayValue),
135139
onUpdate: state.setTimeValue,
136140
format: state.timeFormat,
137141
readOnly: state.readOnly,

0 commit comments

Comments
 (0)