Skip to content

Commit b57bfe4

Browse files
committed
feat(Calendar): support multi-selection
1 parent bbdc641 commit b57bfe4

File tree

8 files changed

+144
-46
lines changed

8 files changed

+144
-46
lines changed

src/components/Calendar/Calendar.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,25 +6,35 @@ import type {DateTime} from '@gravity-ui/date-utils';
66

77
import {CalendarView} from '../CalendarView/CalendarView';
88
import type {CalendarInstance, CalendarSize} from '../CalendarView/CalendarView';
9+
import type {CalendarValueType, SelectionMode} from '../CalendarView/hooks/types';
910
import {useCalendarState} from '../CalendarView/hooks/useCalendarState';
1011
import type {CalendarStateOptions} from '../CalendarView/hooks/useCalendarState';
1112
import type {AccessibilityProps, DomProps, FocusEvents, StyleProps} from '../types';
1213

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

15-
export interface CalendarProps<T = DateTime>
16+
export interface CalendarCommonProps<T = DateTime>
1617
extends CalendarStateOptions<T>, DomProps, StyleProps, FocusEvents, AccessibilityProps {
1718
/**
1819
* The size of the element.
1920
* @default m
2021
*/
2122
size?: CalendarSize;
2223
}
24+
25+
export interface CalendarProps<M extends SelectionMode = 'single'> extends CalendarCommonProps<
26+
CalendarValueType<M>
27+
> {
28+
selectionMode?: M;
29+
}
30+
2331
export const Calendar = React.forwardRef<CalendarInstance, CalendarProps>(function Calendar(
2432
props: CalendarProps,
2533
ref,
2634
) {
2735
const state = useCalendarState(props);
2836

2937
return <CalendarView ref={ref} {...props} state={state} />;
30-
});
38+
}) as <M extends SelectionMode = 'single'>(
39+
props: CalendarProps<M> & React.RefAttributes<CalendarInstance>,
40+
) => React.ReactNode;

src/components/Calendar/README.md

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ LANDING_BLOCK-->
150150

151151
## Focused value
152152

153-
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`.
153+
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`.
154154

155155
<!--LANDING_BLOCK
156156
<ExampleBlock
@@ -170,6 +170,28 @@ LANDING_BLOCK-->
170170

171171
<!--/GITHUB_BLOCK-->
172172

173+
## Multiple selection
174+
175+
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.
176+
177+
<!--LANDING_BLOCK
178+
<ExampleBlock
179+
code={`
180+
<Calendar selectionMode="multiple" />
181+
`}
182+
>
183+
<DateComponentsExamples.CalendarExample selectionMode="multiple" />
184+
</ExampleBlock>
185+
LANDING_BLOCK-->
186+
187+
<!--GITHUB_BLOCK-->
188+
189+
```tsx
190+
<Calendar selectionMode="multiple" />
191+
```
192+
193+
<!--/GITHUB_BLOCK-->
194+
173195
## Time zone
174196

175197
`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)
@@ -194,7 +216,7 @@ LANDING_BLOCK-->
194216
| isWeekend | Callback that is called for each date of the calendar. If it returns true, then the date is weekend. | `((date: DateTime) => boolean)` | |
195217
| [maxValue](#min-and-max-value) | The maximum allowed date that a user may select. | `DateTime` | |
196218
| [minValue](#min-and-max-value) | The minimum allowed date that a user may select. | `DateTime` | |
197-
| [mode](#mode) | Defines the time interval that `Calendar` should display in colttrolled way. | `days` `months` `quarters` `years` | |
219+
| [mode](#mode) | Defines the time interval that `Calendar` should display in controlled way. | `days` `months` `quarters` `years` | |
198220
| modes | Modes available to user | `Partial<Record<CalendarLayout, boolean>>` | `{days: true, months: true, quarters: false, years: true }` |
199221
| onBlur | Fires when the control lost focus. Provides focus event as a callback's argument | `((e: FocusEvent<Element, Element>) => void)` | |
200222
| onFocus | Fires when the control gets focus. Provides focus event as a callback's argument | `((e: FocusEvent<Element, Element>) => void)` | |
@@ -203,6 +225,7 @@ LANDING_BLOCK-->
203225
| onUpdateMode | Fires when the mode is changed. | `((value: 'days' \| 'months' \| 'quarters' \| 'years' ) => void` | |
204226
| [readOnly](#readonly) | Whether the calendar value is immutable. | `boolean` | `false` |
205227
| [size](#size) | The size of the control | `"m"` `"l"` `"xl"` | `"m"` |
228+
| selectionMode | Whether single or multiple selection is enabled. | `'single' \| 'multiple'` | `'single'` |
206229
| style | Sets inline style for the element. | `CSSProperties` | |
207230
| [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` | |
208231
| [value](#calendar) | The value of the control | `DateTime` `null` | |

src/components/CalendarView/hooks/types.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -117,11 +117,16 @@ interface CalendarStateBase {
117117
readonly endDate: DateTime;
118118
}
119119

120-
export interface CalendarState extends CalendarStateBase {
120+
export type SelectionMode = 'single' | 'multiple';
121+
export type CalendarValueType<M extends SelectionMode = 'single'> = M extends 'single'
122+
? DateTime | null
123+
: DateTime[];
124+
125+
export interface CalendarState<M extends SelectionMode = 'single'> extends CalendarStateBase {
121126
/** The currently selected date. */
122-
readonly value: DateTime | null;
127+
readonly value: CalendarValueType<M>;
123128
/** Sets the currently selected date. */
124-
setValue: (value: DateTime) => void;
129+
setValue: (value: DateTime | DateTime[] | null) => void;
125130
}
126131

127132
export interface RangeCalendarState extends CalendarStateBase {

src/components/CalendarView/hooks/useCalendarState.ts

Lines changed: 92 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,16 @@ import {constrainValue, createPlaceholderValue, isWeekend, mergeDateTime} from '
99
import {useDefaultTimeZone} from '../../utils/useDefaultTimeZone';
1010
import {calendarLayouts} from '../utils';
1111

12-
import type {CalendarLayout, CalendarState, CalendarStateOptionsBase} from './types';
12+
import type {
13+
CalendarLayout,
14+
CalendarState,
15+
CalendarStateOptionsBase,
16+
CalendarValueType,
17+
SelectionMode,
18+
} from './types';
1319

1420
export interface CalendarStateOptions<T = DateTime>
15-
extends ValueBase<T | null, T>, CalendarStateOptionsBase {}
21+
extends ValueBase<T | null, Exclude<T, null>>, CalendarStateOptionsBase {}
1622

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

@@ -22,12 +28,14 @@ const defaultModes: Record<CalendarLayout, boolean> = {
2228
quarters: false,
2329
years: true,
2430
};
25-
export function useCalendarState(props: CalendarStateOptions): CalendarState {
26-
const {disabled, readOnly, modes = defaultModes} = props;
27-
const [value, setValue] = useControlledState(
31+
export function useCalendarState<M extends SelectionMode = 'single'>(
32+
props: CalendarStateOptions<CalendarValueType<M>> & {selectionMode?: M},
33+
): CalendarState<M> {
34+
const {disabled, readOnly, modes = defaultModes, selectionMode = 'single' as M} = props;
35+
const [value, setValue] = useControlledState<DateTime | DateTime[] | null>(
2836
props.value,
29-
props.defaultValue ?? null,
30-
props.onUpdate,
37+
props.defaultValue ?? (selectionMode === 'single' ? null : []),
38+
props.onUpdate as any,
3139
);
3240
const availableModes = calendarLayouts.filter((l) => modes[l]);
3341
const minMode = availableModes[0] || 'days';
@@ -39,9 +47,10 @@ export function useCalendarState(props: CalendarStateOptions): CalendarState {
3947
);
4048

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

4352
const inputTimeZone = useDefaultTimeZone(
44-
props.value || props.defaultValue || props.focusedValue || props.defaultFocusedValue,
53+
firstValue || props.focusedValue || props.defaultFocusedValue,
4554
);
4655
const timeZone = props.timeZone || inputTimeZone;
4756

@@ -64,10 +73,11 @@ export function useCalendarState(props: CalendarStateOptions): CalendarState {
6473

6574
const defaultFocusedValue = React.useMemo(() => {
6675
const defaultValue =
67-
(props.defaultFocusedValue ? props.defaultFocusedValue : value)?.timeZone(timeZone) ||
68-
createPlaceholderValue({timeZone}).startOf(minMode);
76+
(props.defaultFocusedValue ? props.defaultFocusedValue : firstValue)?.timeZone(
77+
timeZone,
78+
) || createPlaceholderValue({timeZone}).startOf(minMode);
6979
return constrainValue(defaultValue, minValue, maxValue);
70-
}, [maxValue, minValue, props.defaultFocusedValue, timeZone, value, minMode]);
80+
}, [maxValue, minValue, props.defaultFocusedValue, timeZone, firstValue, minMode]);
7181
const [focusedDateInner, setFocusedDate] = useControlledState(
7282
focusedValue,
7383
defaultFocusedValue,
@@ -94,31 +104,81 @@ export function useCalendarState(props: CalendarStateOptions): CalendarState {
94104
const startDate = getStartDate(focusedDate, currentMode);
95105
const endDate = getEndDate(focusedDate, currentMode);
96106

107+
const finalValue =
108+
selectionMode === 'single'
109+
? firstValue
110+
: Array.isArray(value)
111+
? value
112+
: value
113+
? [value]
114+
: [];
115+
97116
return {
98117
disabled,
99118
readOnly,
100-
value,
101-
setValue(date: DateTime) {
119+
value: finalValue as CalendarValueType<M>,
120+
setValue(date) {
102121
if (!disabled && !readOnly) {
103-
let newValue = constrainValue(date, minValue, maxValue);
104-
if (this.isCellUnavailable(newValue)) {
105-
return;
106-
}
107-
if (value) {
108-
// If there is a date already selected, then we want to keep its time
109-
newValue = mergeDateTime(newValue, value.timeZone(timeZone));
122+
if (selectionMode === 'single') {
123+
let newValue = Array.isArray(date) ? (date[0] ?? null) : date;
124+
if (!newValue) {
125+
setValue(null);
126+
return;
127+
}
128+
newValue = constrainValue(newValue, minValue, maxValue);
129+
if (firstValue) {
130+
// If there is a date already selected, then we want to keep its time
131+
newValue = mergeDateTime(newValue, firstValue.timeZone(timeZone));
132+
}
133+
if (this.isCellUnavailable(newValue)) {
134+
return;
135+
}
136+
setValue(newValue.timeZone(inputTimeZone));
137+
} else {
138+
let dates: DateTime[] = [];
139+
if (Array.isArray(date)) {
140+
dates = date;
141+
} else if (date !== null) {
142+
dates = [date];
143+
}
144+
145+
setValue(dates);
110146
}
111-
setValue(newValue.timeZone(inputTimeZone));
112147
}
113148
},
114149
timeZone,
115150
selectDate(date: DateTime, force = false) {
116151
if (!disabled) {
117152
if (!readOnly && (force || this.mode === minMode)) {
118-
this.setValue(date.startOf(minMode));
119153
if (force && currentMode !== minMode) {
120154
setMode(minMode);
121155
}
156+
const selectedDate = constrainValue(date.startOf(minMode), minValue, maxValue);
157+
if (this.isCellUnavailable(selectedDate)) {
158+
return;
159+
}
160+
if (selectionMode === 'single') {
161+
this.setValue(selectedDate);
162+
} else {
163+
const newValue = Array.isArray(value) ? [...value] : [];
164+
if (value && !Array.isArray(value)) {
165+
newValue.push(value);
166+
}
167+
let found = false;
168+
let index = -1;
169+
while (
170+
(index = newValue.findIndex((d) =>
171+
selectedDate.isSame(d.timeZone(timeZone), currentMode),
172+
)) !== -1
173+
) {
174+
found = true;
175+
newValue.splice(index, 1);
176+
}
177+
if (!found) {
178+
newValue.push(selectedDate.timeZone(inputTimeZone));
179+
}
180+
this.setValue(newValue);
181+
}
122182
} else {
123183
this.zoomIn();
124184
}
@@ -210,18 +270,18 @@ export function useCalendarState(props: CalendarStateOptions): CalendarState {
210270
return this.isInvalid(next);
211271
},
212272
isSelected(date: DateTime) {
213-
return Boolean(
214-
value &&
215-
date.isSame(value.timeZone(timeZone), currentMode) &&
216-
!this.isCellDisabled(date),
217-
);
218-
},
219-
isCellUnavailable(date: DateTime) {
220-
if (this.mode === minMode) {
221-
return Boolean(props.isDateUnavailable && props.isDateUnavailable(date));
222-
} else {
273+
if (!value || !firstValue || this.isCellDisabled(date)) {
223274
return false;
224275
}
276+
if (selectionMode === 'single') {
277+
return date.isSame(firstValue.timeZone(timeZone), currentMode);
278+
}
279+
280+
const dates = Array.isArray(value) ? value : [value];
281+
return dates.some((d) => date.isSame(d.timeZone(timeZone), currentMode));
282+
},
283+
isCellUnavailable(date: DateTime) {
284+
return props.isDateUnavailable ? props.isDateUnavailable(date) : false;
225285
},
226286
isCellFocused(date: DateTime) {
227287
return this.isFocused && focusedDate && date.isSame(focusedDate, currentMode);

src/components/DatePicker/DatePicker.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {Calendar as CalendarIcon, Clock as ClockIcon} from '@gravity-ui/icons';
77
import {Button, Icon, Popup, TextInput, useMobile} from '@gravity-ui/uikit';
88

99
import {Calendar} from '../Calendar';
10-
import type {CalendarProps} from '../Calendar';
10+
import type {CalendarCommonProps} from '../Calendar';
1111
import {DateField} from '../DateField';
1212
import {HiddenInput} from '../HiddenInput/HiddenInput';
1313
import type {
@@ -41,7 +41,7 @@ export interface DatePickerProps<T = DateTime>
4141
StyleProps,
4242
AccessibilityProps,
4343
PopupStyleProps {
44-
children?: (props: CalendarProps<T>) => React.ReactNode;
44+
children?: (props: CalendarCommonProps<T>) => React.ReactNode;
4545
disablePortal?: boolean;
4646
disableFocusTrap?: boolean;
4747
}

src/components/DatePicker/hooks/useDatePickerProps.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type {DateTime} from '@gravity-ui/date-utils';
55
import {useFocusWithin, useForkRef} from '@gravity-ui/uikit';
66
import type {ButtonButtonProps, PopupProps, TextInputProps} from '@gravity-ui/uikit';
77

8-
import type {CalendarInstance, CalendarProps} from '../../Calendar';
8+
import type {CalendarCommonProps, CalendarInstance} from '../../Calendar';
99
import {useDateFieldProps} from '../../DateField';
1010
import type {DateFieldProps} from '../../DateField';
1111
import type {RangeValue} from '../../types';
@@ -24,7 +24,7 @@ interface InnerDatePickerProps<T = DateTime> {
2424
fieldProps: TextInputProps;
2525
calendarButtonProps: ButtonButtonProps & {ref: React.Ref<HTMLButtonElement>};
2626
popupProps: PopupProps;
27-
calendarProps: CalendarProps<T> & {ref: React.Ref<CalendarInstance>};
27+
calendarProps: CalendarCommonProps<T> & {ref: React.Ref<CalendarInstance>};
2828
timeInputProps: DateFieldProps<T>;
2929
}
3030

src/components/RangeCalendar/RangeCalendar.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,15 @@ import React from 'react';
44

55
import type {DateTime} from '@gravity-ui/date-utils';
66

7-
import type {CalendarProps} from '../Calendar/Calendar';
7+
import type {CalendarCommonProps} from '../Calendar/Calendar';
88
import {CalendarView} from '../CalendarView/CalendarView';
99
import type {CalendarInstance} from '../CalendarView/CalendarView';
1010
import {useRangeCalendarState} from '../CalendarView/hooks/useRangeCalendarState';
1111
import type {RangeValue} from '../types';
1212

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

15-
export type RangeCalendarProps = CalendarProps<RangeValue<DateTime>>;
15+
export type RangeCalendarProps = CalendarCommonProps<RangeValue<DateTime>>;
1616

1717
export const RangeCalendar = React.forwardRef<CalendarInstance, RangeCalendarProps>(
1818
function Calendar(props: RangeCalendarProps, ref) {

src/components/RelativeDatePicker/hooks/useRelativeDatePickerProps.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ interface InnerRelativeDatePickerProps {
2222
modeSwitcherProps: ButtonButtonProps;
2323
calendarButtonProps: ButtonButtonProps;
2424
popupProps: PopupProps;
25-
calendarProps: React.ComponentProps<typeof Calendar>;
25+
calendarProps: React.ComponentProps<typeof Calendar<'single'>>;
2626
timeInputProps: DateFieldProps;
2727
}
2828

0 commit comments

Comments
 (0)