Skip to content

Commit

Permalink
feat: add a new range date picker component (#47)
Browse files Browse the repository at this point in the history
Co-authored-by: Konstantin Rozhkov <[email protected]>
  • Loading branch information
krozhkov and Konstantin Rozhkov authored Mar 27, 2024
1 parent 239530f commit ce19b43
Show file tree
Hide file tree
Showing 21 changed files with 534 additions and 232 deletions.
1 change: 1 addition & 0 deletions src/components/CalendarView/hooks/useCalendarState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {CalendarLayout, CalendarState, CalendarStateOptionsBase} from './ty
export interface CalendarStateOptions<T = DateTime>
extends ValueBase<T | null, T>,
CalendarStateOptionsBase {}

export type {CalendarState} from './types';

const defaultModes: Record<CalendarLayout, boolean> = {
Expand Down
4 changes: 2 additions & 2 deletions src/components/DateField/hooks/useBaseDateFieldState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export type BaseDateFieldStateOptions<T = DateTime> = {
setValueFromString: (str: string) => boolean;
};

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

export function useBaseDateFieldState<T = DateTime>(
props: BaseDateFieldStateOptions<T>,
): BaseDateFieldState<T> {
): DateFieldState<T> {
const {
value,
validationState,
Expand Down
4 changes: 2 additions & 2 deletions src/components/DateField/hooks/useDateFieldProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import type {
} from '../../types';
import {cleanString} from '../utils';

import type {BaseDateFieldState} from './useBaseDateFieldState';
import type {DateFieldState} from './useBaseDateFieldState';

export interface DateFieldProps<T = DateTime>
extends DateFieldBase<T>,
Expand All @@ -30,7 +30,7 @@ export interface DateFieldProps<T = DateTime>
AccessibilityProps {}

export function useDateFieldProps<T = DateTime>(
state: BaseDateFieldState<T>,
state: DateFieldState<T>,
props: DateFieldProps<T>,
): {inputProps: TextInputProps} {
const inputRef = React.useRef<HTMLInputElement>(null);
Expand Down
4 changes: 1 addition & 3 deletions src/components/DateField/hooks/useDateFieldState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,10 @@ import {
} from '../utils';

import {useBaseDateFieldState} from './useBaseDateFieldState';
import type {BaseDateFieldState} from './useBaseDateFieldState';
import type {DateFieldState} from './useBaseDateFieldState';

export interface DateFieldStateOptions extends DateFieldBase {}

export type DateFieldState = BaseDateFieldState;

export function useDateFieldState(props: DateFieldStateOptions): DateFieldState {
const [value, setDate] = useControlledState(
props.value,
Expand Down
1 change: 1 addition & 0 deletions src/components/DateField/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ function getDateSectionConfigFromFormatToken(formatToken: string): {
const config = formatTokenMap[formatToken];

if (!config) {
// eslint-disable-next-line no-console
console.error(
[
`The token "${formatToken}" is not supported by the Date field.`,
Expand Down
7 changes: 4 additions & 3 deletions src/components/DatePicker/DatePicker.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from 'react';

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

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

import './DatePicker.scss';

export interface DatePickerProps
extends DateFieldBase,
export interface DatePickerProps<T = DateTime>
extends DateFieldBase<T>,
TextInputProps,
FocusableProps,
KeyboardEvents,
DomProps,
InputDOMProps,
StyleProps,
AccessibilityProps {
children?: (props: CalendarProps) => React.ReactNode;
children?: (props: CalendarProps<T>) => React.ReactNode;
}

export function DatePicker({className, ...props}: DatePickerProps) {
Expand Down
207 changes: 207 additions & 0 deletions src/components/DatePicker/hooks/datePickerStateFactory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import React from 'react';

import type {DateTime} from '@gravity-ui/date-utils';
import {useControlledState} from '@gravity-ui/uikit';

import type {DateFieldState} from '../../DateField';
import type {DateFieldBase} from '../../types';
import {useDefaultTimeZone} from '../../utils/useDefaultTimeZone';

export interface DatePickerState<T = DateTime> {
/** The currently selected date. */
value: T | null;
/** Sets the selected date. */
setValue: (value: T | null) => void;
/**
* The date portion of the value. This may be set prior to `value` if the user has
* selected a date but has not yet selected a time.
*/
dateValue: T | null;
/** Sets the date portion of the value. */
setDateValue: (value: T) => void;
/**
* The time portion of the value. This may be set prior to `value` if the user has
* selected a time but has not yet selected a date.
*/
timeValue: T | null;
/** Sets the time portion of the value. */
setTimeValue: (value: T | null) => void;
/** Whether the field is read only. */
readOnly?: boolean;
/** Whether the field is disabled. */
disabled?: boolean;
/** Format of the date when rendered in the input. */
format: string;
/** Whether the date picker supports selecting a date. */
hasDate: boolean;
/** Whether the date picker supports selecting a time. */
hasTime: boolean;
/** Format of the time when rendered in the input. */
timeFormat?: string;
timeZone: string;
/** Whether the calendar popover is currently open. */
isOpen: boolean;
/** Sets whether the calendar popover is open. */
setOpen: (isOpen: boolean) => void;
dateFieldState: DateFieldState<T>;
}

export interface DatePickerStateFactoryOptions<T, O extends DateFieldBase<T>> {
getPlaceholderTime: (placeholderValue: DateTime | undefined, timeZone?: string) => T;
mergeDateTime: (date: T, time: T) => T;
setTimezone: (date: T, timeZone: string) => T;
getDateTime: (date: T | null | undefined) => DateTime | undefined;
useDateFieldState: (props: O) => DateFieldState<T>;
}

export function datePickerStateFactory<T, O extends DateFieldBase<T>>({
getPlaceholderTime,
mergeDateTime,
setTimezone,
getDateTime,
useDateFieldState,
}: DatePickerStateFactoryOptions<T, O>) {
return function useDatePickerState(props: O): DatePickerState<T> {
const {disabled, readOnly} = props;
const [isOpen, setOpen] = React.useState(false);

const [value, setValue] = useControlledState(
props.value as never,
props.defaultValue ?? null,
props.onUpdate,
);
const [selectedDateInner, setSelectedDate] = React.useState<T | null>(null);
const [selectedTimeInner, setSelectedTime] = React.useState<T | null>(null);

const inputTimeZone = useDefaultTimeZone(
getDateTime(props.value) || getDateTime(props.defaultValue) || props.placeholderValue,
);
const timeZone = props.timeZone || inputTimeZone;

let selectedDate = selectedDateInner;
let selectedTime = selectedTimeInner;

const format = props.format || 'L';

const commitValue = (date: T, time: T) => {
if (disabled || readOnly) {
return;
}

setValue(setTimezone(mergeDateTime(date, time), inputTimeZone));
setSelectedDate(null);
setSelectedTime(null);
};

const dateFieldState = useDateFieldState({
...props,
value,
onUpdate(date: T | null) {
if (date) {
commitValue(date, date);
} else {
setValue(null);
}
},
disabled,
readOnly,
validationState: props.validationState,
minValue: props.minValue,
maxValue: props.maxValue,
isDateUnavailable: props.isDateUnavailable,
format,
placeholderValue: props.placeholderValue,
timeZone,
});

const timeFormat = React.useMemo(() => {
const hasSeconds = dateFieldState.sections.some((s) => s.type === 'second');
return hasSeconds ? 'LTS' : 'LT';
}, [dateFieldState.sections]);

if (value) {
selectedDate = setTimezone(value, timeZone);
if (dateFieldState.hasTime) {
selectedTime = setTimezone(value, timeZone);
}
}

// Intercept setValue to make sure the Time section is not changed by date selection in Calendar
const selectDate = (newValue: T) => {
if (disabled || readOnly) {
return;
}

const shouldClose = !dateFieldState.hasTime;
if (dateFieldState.hasTime) {
if (selectedTime || shouldClose) {
commitValue(newValue, selectedTime || newValue);
} else {
setSelectedDate(newValue);
}
} else {
commitValue(newValue, newValue);
}

if (shouldClose) {
setOpen(false);
}
};

const selectTime = (newValue: T | null) => {
if (disabled || readOnly) {
return;
}

const newTime = newValue ?? getPlaceholderTime(props.placeholderValue, timeZone);

if (selectedDate) {
commitValue(selectedDate, newTime);
} else {
setSelectedTime(newTime);
}
};

if (dateFieldState.hasTime && !selectedTime) {
selectedTime = dateFieldState.displayValue;
}

return {
value,
setValue(newDate: T | null) {
if (props.readOnly || props.disabled) {
return;
}

if (newDate) {
setValue(setTimezone(newDate, inputTimeZone));
} else {
setValue(null);
}
},
dateValue: selectedDate,
timeValue: selectedTime,
setDateValue: selectDate,
setTimeValue: selectTime,
disabled,
readOnly,
format,
hasDate: dateFieldState.hasDate,
hasTime: dateFieldState.hasTime,
timeFormat,
timeZone,
isOpen,
setOpen(newIsOpen) {
if (!newIsOpen && !value && selectedDate && dateFieldState.hasTime) {
commitValue(
selectedDate,
selectedTime || getPlaceholderTime(props.placeholderValue, props.timeZone),
);
}

setOpen(newIsOpen);
},
dateFieldState,
};
};
}
22 changes: 13 additions & 9 deletions src/components/DatePicker/hooks/useDatePickerProps.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,35 @@
import React from 'react';

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

import type {Calendar, CalendarInstance} from '../../Calendar';
import type {CalendarInstance, CalendarProps} from '../../Calendar';
import {useDateFieldProps} from '../../DateField';
import type {DateFieldProps} from '../../DateField';
import type {RangeValue} from '../../types';
import {getButtonSizeForInput} from '../../utils/getButtonSizeForInput';
import {mergeProps} from '../../utils/mergeProps';
import type {DatePickerProps} from '../DatePicker';
import {i18n} from '../i18n';
import {getDateTimeValue} from '../utils';

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

interface InnerRelativeDatePickerProps {
interface InnerDatePickerProps<T = DateTime> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
groupProps: React.HTMLAttributes<unknown> & {ref: React.Ref<any>};
fieldProps: TextInputProps;
calendarButtonProps: ButtonProps & {ref: React.Ref<HTMLButtonElement>};
popupProps: PopupProps;
calendarProps: React.ComponentProps<typeof Calendar>;
timeInputProps: DateFieldProps;
calendarProps: CalendarProps<T> & {ref: React.Ref<CalendarInstance>};
timeInputProps: DateFieldProps<T>;
}

export function useDatePickerProps(
state: DatePickerState,
{onFocus, onBlur, ...props}: DatePickerProps,
): InnerRelativeDatePickerProps {
export function useDatePickerProps<T extends DateTime | RangeValue<DateTime>>(
state: DatePickerState<T>,
{onFocus, onBlur, ...props}: DatePickerProps<T>,
): InnerDatePickerProps<T> {
const [isActive, setActive] = React.useState(false);

const {focusWithinProps} = useFocusWithin({
Expand Down Expand Up @@ -131,7 +135,7 @@ export function useDatePickerProps(
},
timeInputProps: {
value: state.timeValue,
placeholderValue: state.dateFieldState.displayValue,
placeholderValue: getDateTimeValue(state.dateFieldState.displayValue),
onUpdate: state.setTimeValue,
format: state.timeFormat,
readOnly: state.readOnly,
Expand Down
Loading

0 comments on commit ce19b43

Please sign in to comment.