Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions src/components/Calendar/Calendar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,14 @@ import type {DateTime} from '@gravity-ui/date-utils';

import {CalendarView} from '../CalendarView/CalendarView';
import type {CalendarInstance, CalendarSize} from '../CalendarView/CalendarView';
import type {CalendarValueType, SelectionMode} from '../CalendarView/hooks/types';
import {useCalendarState} from '../CalendarView/hooks/useCalendarState';
import type {CalendarStateOptions} from '../CalendarView/hooks/useCalendarState';
import type {AccessibilityProps, DomProps, FocusEvents, StyleProps} from '../types';

import '../CalendarView/Calendar.scss';

export interface CalendarProps<T = DateTime>
export interface CalendarCommonProps<T = DateTime>
extends CalendarStateOptions<T>,
DomProps,
StyleProps,
Expand All @@ -24,11 +25,19 @@ export interface CalendarProps<T = DateTime>
*/
size?: CalendarSize;
}

export interface CalendarProps<M extends SelectionMode = 'single'>
extends CalendarCommonProps<CalendarValueType<M>> {
selectionMode?: M;
}

export const Calendar = React.forwardRef<CalendarInstance, CalendarProps>(function Calendar(
props: CalendarProps,
ref,
) {
const state = useCalendarState(props);

return <CalendarView ref={ref} {...props} state={state} />;
});
}) as <M extends SelectionMode = 'single'>(
props: CalendarProps<M> & React.RefAttributes<CalendarInstance>,
) => React.ReactNode;
27 changes: 25 additions & 2 deletions src/components/Calendar/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ LANDING_BLOCK-->

## Focused value

Allows to select the date that `Calendar` view is focused on. If you need it to be controlled you shoud use `focusedValue` prop. You can set the initial focused value for uncontrolled component with optional prop `defaultFocusedValue`.
Allows to select the date that `Calendar` view is focused on. If you need it to be controlled you should use `focusedValue` prop. You can set the initial focused value for uncontrolled component with optional prop `defaultFocusedValue`.

<!--LANDING_BLOCK
<ExampleBlock
Expand All @@ -170,6 +170,28 @@ LANDING_BLOCK-->

<!--/GITHUB_BLOCK-->

## Multiple selection

Set the `selectionMode="multiple"` prop to enable the user to select multiple dates. When multiple selection is enabled, the value prop should be an array of dates instead of a single date, and onChange will be called with an array.

<!--LANDING_BLOCK
<ExampleBlock
code={`
<Calendar selectionMode="multiple" />
`}
>
<DateComponentsExamples.CalendarExample selectionMode="multiple" />
</ExampleBlock>
LANDING_BLOCK-->

<!--GITHUB_BLOCK-->

```tsx
<Calendar selectionMode="multiple" />
```

<!--/GITHUB_BLOCK-->

## Time zone

`timeZone` is the property to set the time zone of the value in the input. [Learn more about time zones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List)
Expand All @@ -194,7 +216,7 @@ LANDING_BLOCK-->
| isWeekend | Callback that is called for each date of the calendar. If it returns true, then the date is weekend. | `((date: DateTime) => boolean)` | |
| [maxValue](#min-and-max-value) | The maximum allowed date that a user may select. | `DateTime` | |
| [minValue](#min-and-max-value) | The minimum allowed date that a user may select. | `DateTime` | |
| [mode](#mode) | Defines the time interval that `Calendar` should display in colttrolled way. | `days` `months` `quarters` `years` | |
| [mode](#mode) | Defines the time interval that `Calendar` should display in controlled way. | `days` `months` `quarters` `years` | |
| modes | Modes available to user | `Partial<Record<CalendarLayout, boolean>>` | `{days: true, months: true, quarters: false, years: true }` |
| onBlur | Fires when the control lost focus. Provides focus event as a callback's argument | `((e: FocusEvent<Element, Element>) => void)` | |
| onFocus | Fires when the control gets focus. Provides focus event as a callback's argument | `((e: FocusEvent<Element, Element>) => void)` | |
Expand All @@ -203,6 +225,7 @@ LANDING_BLOCK-->
| onUpdateMode | Fires when the mode is changed. | `((value: 'days' \| 'months' \| 'quarters' \| 'years' ) => void` | |
| [readOnly](#readonly) | Whether the calendar value is immutable. | `boolean` | `false` |
| [size](#size) | The size of the control | `"m"` `"l"` `"xl"` | `"m"` |
| selectionMode | Whether single or multiple selection is enabled. | `'single' \| 'multiple'` | `'single'` |
| style | Sets inline style for the element. | `CSSProperties` | |
| [timeZone](#time-zone) | Sets the time zone. [Learn more about time zones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List) | `string` | |
| [value](#calendar) | The value of the control | `DateTime` `null` | |
33 changes: 29 additions & 4 deletions src/components/Calendar/__stories__/Calendar.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,13 @@ 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 ? dateTimeParse(args.value, {timeZone}) : undefined,
value: args.value ? dateTimeParse(args.value, {timeZone}) : args.value,
defaultValue: args.defaultValue
? dateTimeParse(args.defaultValue, {timeZone})
: undefined,
: args.defaultValue,
focusedValue: args.focusedValue
? dateTimeParse(args.focusedValue, {timeZone})
: undefined,
: args.focusedValue,
defaultFocusedValue: args.defaultFocusedValue
? dateTimeParse(args.defaultFocusedValue, {timeZone})
: undefined,
Expand All @@ -53,7 +53,7 @@ export const Default = meta.story({
theme: 'success',
content: (
<div>
<div>date: {resArray.map((r) => r.format()).join(', ')}</div>
<div>date: {resArray.map((r) => r.format()).join(', ') || `[]`}</div>
</div>
),
});
Expand Down Expand Up @@ -167,3 +167,28 @@ export const Custom = Default.extend({
controls: {exclude: ['mode', 'defaultMode', 'modes']},
},
});

export const ClearableCalendar = Default.extend({
render: function ClearableCalendar(props) {
const [value, setValue] = React.useState<DateTime | null>(null);
return (
<Default.Component
{...props}
selectionMode="single"
value={value}
// @ts-expect-error
onUpdate={(v: DateTime) => {
if (v.isSame(value, 'day')) {
setValue(null);
} else {
setValue(v);
}
props.onUpdate?.(v);
}}
/>
);
},
parameters: {
controls: {exclude: ['selectionMode', 'value', 'defaultValue']},
},
});
11 changes: 8 additions & 3 deletions src/components/CalendarView/hooks/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,11 +117,16 @@ interface CalendarStateBase {
readonly endDate: DateTime;
}

export interface CalendarState extends CalendarStateBase {
export type SelectionMode = 'single' | 'multiple';
export type CalendarValueType<M extends SelectionMode = 'single'> = M extends 'single'
? DateTime | null
: DateTime[];

export interface CalendarState<M extends SelectionMode = 'single'> extends CalendarStateBase {
/** The currently selected date. */
readonly value: DateTime | null;
readonly value: CalendarValueType<M>;
/** Sets the currently selected date. */
setValue: (value: DateTime) => void;
setValue: (value: DateTime | DateTime[] | null) => void;
}

export interface RangeCalendarState extends CalendarStateBase {
Expand Down
124 changes: 92 additions & 32 deletions src/components/CalendarView/hooks/useCalendarState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,16 @@
import {useDefaultTimeZone} from '../../utils/useDefaultTimeZone';
import {calendarLayouts} from '../utils';

import type {CalendarLayout, CalendarState, CalendarStateOptionsBase} from './types';
import type {
CalendarLayout,
CalendarState,
CalendarStateOptionsBase,
CalendarValueType,
SelectionMode,
} from './types';

export interface CalendarStateOptions<T = DateTime>
extends ValueBase<T | null, T>,
extends ValueBase<T | null, Exclude<T, null>>,
CalendarStateOptionsBase {}

export type {CalendarState} from './types';
Expand All @@ -23,12 +29,14 @@
quarters: false,
years: true,
};
export function useCalendarState(props: CalendarStateOptions): CalendarState {
const {disabled, readOnly, modes = defaultModes} = props;
const [value, setValue] = useControlledState(
export function useCalendarState<M extends SelectionMode = 'single'>(
props: CalendarStateOptions<CalendarValueType<M>> & {selectionMode?: M},
): CalendarState<M> {
const {disabled, readOnly, modes = defaultModes, selectionMode = 'single' as M} = props;
const [value, setValue] = useControlledState<DateTime | DateTime[] | null>(
props.value,
props.defaultValue ?? null,
props.onUpdate,
props.defaultValue ?? (selectionMode === 'single' ? null : []),
props.onUpdate as any,

Check warning on line 39 in src/components/CalendarView/hooks/useCalendarState.ts

View workflow job for this annotation

GitHub Actions / Verify Files

Unexpected any. Specify a different type
);
const availableModes = calendarLayouts.filter((l) => modes[l]);
const minMode = availableModes[0] || 'days';
Expand All @@ -40,9 +48,10 @@
);

const currentMode = mode && availableModes.includes(mode) ? mode : minMode;
const firstValue = Array.isArray(value) ? (value[0] ?? null) : value;

const inputTimeZone = useDefaultTimeZone(
props.value || props.defaultValue || props.focusedValue || props.defaultFocusedValue,
firstValue || props.focusedValue || props.defaultFocusedValue,
);
const timeZone = props.timeZone || inputTimeZone;

Expand All @@ -65,10 +74,11 @@

const defaultFocusedValue = React.useMemo(() => {
const defaultValue =
(props.defaultFocusedValue ? props.defaultFocusedValue : value)?.timeZone(timeZone) ||
createPlaceholderValue({timeZone}).startOf(minMode);
(props.defaultFocusedValue ? props.defaultFocusedValue : firstValue)?.timeZone(
timeZone,
) || createPlaceholderValue({timeZone}).startOf(minMode);
return constrainValue(defaultValue, minValue, maxValue);
}, [maxValue, minValue, props.defaultFocusedValue, timeZone, value, minMode]);
}, [maxValue, minValue, props.defaultFocusedValue, timeZone, firstValue, minMode]);
const [focusedDateInner, setFocusedDate] = useControlledState(
focusedValue,
defaultFocusedValue,
Expand All @@ -95,31 +105,81 @@
const startDate = getStartDate(focusedDate, currentMode);
const endDate = getEndDate(focusedDate, currentMode);

const finalValue =
selectionMode === 'single'

Check warning on line 109 in src/components/CalendarView/hooks/useCalendarState.ts

View workflow job for this annotation

GitHub Actions / Verify Files

Do not nest ternary expressions
? firstValue
: Array.isArray(value)

Check warning on line 111 in src/components/CalendarView/hooks/useCalendarState.ts

View workflow job for this annotation

GitHub Actions / Verify Files

Do not nest ternary expressions
? value
: value
? [value]
: [];

return {
disabled,
readOnly,
value,
setValue(date: DateTime) {
value: finalValue as CalendarValueType<M>,
setValue(date) {
if (!disabled && !readOnly) {
let newValue = constrainValue(date, minValue, maxValue);
if (this.isCellUnavailable(newValue)) {
return;
}
if (value) {
// If there is a date already selected, then we want to keep its time
newValue = mergeDateTime(newValue, value.timeZone(timeZone));
if (selectionMode === 'single') {
let newValue = Array.isArray(date) ? (date[0] ?? null) : date;
if (!newValue) {
setValue(null);
return;
}
newValue = constrainValue(newValue, minValue, maxValue);
if (firstValue) {
// If there is a date already selected, then we want to keep its time
newValue = mergeDateTime(newValue, firstValue.timeZone(timeZone));
}
if (this.isCellUnavailable(newValue)) {
return;
}
setValue(newValue.timeZone(inputTimeZone));
} else {
let dates: DateTime[] = [];
if (Array.isArray(date)) {
dates = date;
} else if (date !== null) {
dates = [date];
}

setValue(dates);
}
setValue(newValue.timeZone(inputTimeZone));
}
},
timeZone,
selectDate(date: DateTime, force = false) {
if (!disabled) {
if (!readOnly && (force || this.mode === minMode)) {
this.setValue(date.startOf(minMode));
if (force && currentMode !== minMode) {
setMode(minMode);
}
const selectedDate = constrainValue(date.startOf(minMode), minValue, maxValue);
if (this.isCellUnavailable(selectedDate)) {
return;
}
if (selectionMode === 'single') {
this.setValue(selectedDate);
} else {
const newValue = Array.isArray(value) ? [...value] : [];
if (value && !Array.isArray(value)) {
newValue.push(value);
}
let found = false;
let index = -1;
while (
(index = newValue.findIndex((d) =>
selectedDate.isSame(d.timeZone(timeZone), currentMode),
)) !== -1
) {
found = true;
newValue.splice(index, 1);
}
if (!found) {
newValue.push(selectedDate.timeZone(inputTimeZone));
}
this.setValue(newValue);
}
} else {
this.zoomIn();
}
Expand Down Expand Up @@ -211,18 +271,18 @@
return this.isInvalid(next);
},
isSelected(date: DateTime) {
return Boolean(
value &&
date.isSame(value.timeZone(timeZone), currentMode) &&
!this.isCellDisabled(date),
);
},
isCellUnavailable(date: DateTime) {
if (this.mode === minMode) {
return Boolean(props.isDateUnavailable && props.isDateUnavailable(date));
} else {
if (!value || !firstValue || this.isCellDisabled(date)) {
return false;
}
if (selectionMode === 'single') {
return date.isSame(firstValue.timeZone(timeZone), currentMode);
}

const dates = Array.isArray(value) ? value : [value];
return dates.some((d) => date.isSame(d.timeZone(timeZone), currentMode));
},
isCellUnavailable(date: DateTime) {
return props.isDateUnavailable ? props.isDateUnavailable(date) : false;
},
isCellFocused(date: DateTime) {
return this.isFocused && focusedDate && date.isSame(focusedDate, currentMode);
Expand Down
4 changes: 2 additions & 2 deletions src/components/DatePicker/DatePicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {Calendar as CalendarIcon, Clock as ClockIcon} from '@gravity-ui/icons';
import {Button, Icon, Popup, TextInput, useMobile} from '@gravity-ui/uikit';

import {Calendar} from '../Calendar';
import type {CalendarProps} from '../Calendar';
import type {CalendarCommonProps} from '../Calendar';
import {DateField} from '../DateField';
import {HiddenInput} from '../HiddenInput/HiddenInput';
import type {
Expand Down Expand Up @@ -40,7 +40,7 @@ export interface DatePickerProps<T = DateTime>
StyleProps,
AccessibilityProps,
PopupStyleProps {
children?: (props: CalendarProps<T>) => React.ReactNode;
children?: (props: CalendarCommonProps<T>) => React.ReactNode;
disablePortal?: boolean;
disableFocusTrap?: boolean;
}
Expand Down
4 changes: 2 additions & 2 deletions src/components/DatePicker/hooks/useDatePickerProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type {DateTime} from '@gravity-ui/date-utils';
import {useFocusWithin, useForkRef} from '@gravity-ui/uikit';
import type {ButtonButtonProps, PopupProps, TextInputProps} from '@gravity-ui/uikit';

import type {CalendarInstance, CalendarProps} from '../../Calendar';
import type {CalendarCommonProps, CalendarInstance} from '../../Calendar';
import {useDateFieldProps} from '../../DateField';
import type {DateFieldProps} from '../../DateField';
import type {RangeValue} from '../../types';
Expand All @@ -24,7 +24,7 @@ interface InnerDatePickerProps<T = DateTime> {
fieldProps: TextInputProps;
calendarButtonProps: ButtonButtonProps & {ref: React.Ref<HTMLButtonElement>};
popupProps: PopupProps;
calendarProps: CalendarProps<T> & {ref: React.Ref<CalendarInstance>};
calendarProps: CalendarCommonProps<T> & {ref: React.Ref<CalendarInstance>};
timeInputProps: DateFieldProps<T>;
}

Expand Down
Loading
Loading