Skip to content

Commit bcb3e99

Browse files
committed
feat(Calendar): support multi-selection
1 parent aaf8d88 commit bcb3e99

File tree

9 files changed

+172
-50
lines changed

9 files changed

+172
-50
lines changed

src/components/Calendar/Calendar.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,14 @@ 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>,
1718
DomProps,
1819
StyleProps,
@@ -24,11 +25,19 @@ export interface CalendarProps<T = DateTime>
2425
*/
2526
size?: CalendarSize;
2627
}
28+
29+
export interface CalendarProps<M extends SelectionMode = 'single'>
30+
extends CalendarCommonProps<CalendarValueType<M>> {
31+
selectionMode?: M;
32+
}
33+
2734
export const Calendar = React.forwardRef<CalendarInstance, CalendarProps>(function Calendar(
2835
props: CalendarProps,
2936
ref,
3037
) {
3138
const state = useCalendarState(props);
3239

3340
return <CalendarView ref={ref} {...props} state={state} />;
34-
});
41+
}) as <M extends SelectionMode = 'single'>(
42+
props: CalendarProps<M> & React.RefAttributes<CalendarInstance>,
43+
) => 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/Calendar/__stories__/Calendar.stories.tsx

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,13 @@ export const Default = meta.story({
2828
...args,
2929
minValue: args.minValue ? dateTimeParse(args.minValue, {timeZone}) : undefined,
3030
maxValue: args.maxValue ? dateTimeParse(args.maxValue, {timeZone}) : undefined,
31-
value: args.value ? dateTimeParse(args.value, {timeZone}) : undefined,
31+
value: args.value ? dateTimeParse(args.value, {timeZone}) : args.value,
3232
defaultValue: args.defaultValue
3333
? dateTimeParse(args.defaultValue, {timeZone})
34-
: undefined,
34+
: args.defaultValue,
3535
focusedValue: args.focusedValue
3636
? dateTimeParse(args.focusedValue, {timeZone})
37-
: undefined,
37+
: args.focusedValue,
3838
defaultFocusedValue: args.defaultFocusedValue
3939
? dateTimeParse(args.defaultFocusedValue, {timeZone})
4040
: undefined,
@@ -53,7 +53,7 @@ export const Default = meta.story({
5353
theme: 'success',
5454
content: (
5555
<div>
56-
<div>date: {resArray.map((r) => r.format()).join(', ')}</div>
56+
<div>date: {resArray.map((r) => r.format()).join(', ') || `[]`}</div>
5757
</div>
5858
),
5959
});
@@ -167,3 +167,28 @@ export const Custom = Default.extend({
167167
controls: {exclude: ['mode', 'defaultMode', 'modes']},
168168
},
169169
});
170+
171+
export const ClearableCalendar = Default.extend({
172+
render: function ClearableCalendar(props) {
173+
const [value, setValue] = React.useState<DateTime | null>(null);
174+
return (
175+
<Default.Component
176+
{...props}
177+
selectionMode="single"
178+
value={value}
179+
// @ts-expect-error
180+
onUpdate={(v: DateTime) => {
181+
if (v.isSame(value, 'day')) {
182+
setValue(null);
183+
} else {
184+
setValue(v);
185+
}
186+
props.onUpdate?.(v);
187+
}}
188+
/>
189+
);
190+
},
191+
parameters: {
192+
controls: {exclude: ['selectionMode', 'value', 'defaultValue']},
193+
},
194+
});

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>,
21+
extends ValueBase<T | null, Exclude<T, null>>,
1622
CalendarStateOptionsBase {}
1723

1824
export type {CalendarState} from './types';
@@ -23,12 +29,14 @@ const defaultModes: Record<CalendarLayout, boolean> = {
2329
quarters: false,
2430
years: true,
2531
};
26-
export function useCalendarState(props: CalendarStateOptions): CalendarState {
27-
const {disabled, readOnly, modes = defaultModes} = props;
28-
const [value, setValue] = useControlledState(
32+
export function useCalendarState<M extends SelectionMode = 'single'>(
33+
props: CalendarStateOptions<CalendarValueType<M>> & {selectionMode?: M},
34+
): CalendarState<M> {
35+
const {disabled, readOnly, modes = defaultModes, selectionMode = 'single' as M} = props;
36+
const [value, setValue] = useControlledState<DateTime | DateTime[] | null>(
2937
props.value,
30-
props.defaultValue ?? null,
31-
props.onUpdate,
38+
props.defaultValue ?? (selectionMode === 'single' ? null : []),
39+
props.onUpdate as any,
3240
);
3341
const availableModes = calendarLayouts.filter((l) => modes[l]);
3442
const minMode = availableModes[0] || 'days';
@@ -40,9 +48,10 @@ export function useCalendarState(props: CalendarStateOptions): CalendarState {
4048
);
4149

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

4453
const inputTimeZone = useDefaultTimeZone(
45-
props.value || props.defaultValue || props.focusedValue || props.defaultFocusedValue,
54+
firstValue || props.focusedValue || props.defaultFocusedValue,
4655
);
4756
const timeZone = props.timeZone || inputTimeZone;
4857

@@ -65,10 +74,11 @@ export function useCalendarState(props: CalendarStateOptions): CalendarState {
6574

6675
const defaultFocusedValue = React.useMemo(() => {
6776
const defaultValue =
68-
(props.defaultFocusedValue ? props.defaultFocusedValue : value)?.timeZone(timeZone) ||
69-
createPlaceholderValue({timeZone}).startOf(minMode);
77+
(props.defaultFocusedValue ? props.defaultFocusedValue : firstValue)?.timeZone(
78+
timeZone,
79+
) || createPlaceholderValue({timeZone}).startOf(minMode);
7080
return constrainValue(defaultValue, minValue, maxValue);
71-
}, [maxValue, minValue, props.defaultFocusedValue, timeZone, value, minMode]);
81+
}, [maxValue, minValue, props.defaultFocusedValue, timeZone, firstValue, minMode]);
7282
const [focusedDateInner, setFocusedDate] = useControlledState(
7383
focusedValue,
7484
defaultFocusedValue,
@@ -95,31 +105,81 @@ export function useCalendarState(props: CalendarStateOptions): CalendarState {
95105
const startDate = getStartDate(focusedDate, currentMode);
96106
const endDate = getEndDate(focusedDate, currentMode);
97107

108+
const finalValue =
109+
selectionMode === 'single'
110+
? firstValue
111+
: Array.isArray(value)
112+
? value
113+
: value
114+
? [value]
115+
: [];
116+
98117
return {
99118
disabled,
100119
readOnly,
101-
value,
102-
setValue(date: DateTime) {
120+
value: finalValue as CalendarValueType<M>,
121+
setValue(date) {
103122
if (!disabled && !readOnly) {
104-
let newValue = constrainValue(date, minValue, maxValue);
105-
if (this.isCellUnavailable(newValue)) {
106-
return;
107-
}
108-
if (value) {
109-
// If there is a date already selected, then we want to keep its time
110-
newValue = mergeDateTime(newValue, value.timeZone(timeZone));
123+
if (selectionMode === 'single') {
124+
let newValue = Array.isArray(date) ? (date[0] ?? null) : date;
125+
if (!newValue) {
126+
setValue(null);
127+
return;
128+
}
129+
newValue = constrainValue(newValue, minValue, maxValue);
130+
if (firstValue) {
131+
// If there is a date already selected, then we want to keep its time
132+
newValue = mergeDateTime(newValue, firstValue.timeZone(timeZone));
133+
}
134+
if (this.isCellUnavailable(newValue)) {
135+
return;
136+
}
137+
setValue(newValue.timeZone(inputTimeZone));
138+
} else {
139+
let dates: DateTime[] = [];
140+
if (Array.isArray(date)) {
141+
dates = date;
142+
} else if (date !== null) {
143+
dates = [date];
144+
}
145+
146+
setValue(dates);
111147
}
112-
setValue(newValue.timeZone(inputTimeZone));
113148
}
114149
},
115150
timeZone,
116151
selectDate(date: DateTime, force = false) {
117152
if (!disabled) {
118153
if (!readOnly && (force || this.mode === minMode)) {
119-
this.setValue(date.startOf(minMode));
120154
if (force && currentMode !== minMode) {
121155
setMode(minMode);
122156
}
157+
const selectedDate = constrainValue(date.startOf(minMode), minValue, maxValue);
158+
if (this.isCellUnavailable(selectedDate)) {
159+
return;
160+
}
161+
if (selectionMode === 'single') {
162+
this.setValue(selectedDate);
163+
} else {
164+
const newValue = Array.isArray(value) ? [...value] : [];
165+
if (value && !Array.isArray(value)) {
166+
newValue.push(value);
167+
}
168+
let found = false;
169+
let index = -1;
170+
while (
171+
(index = newValue.findIndex((d) =>
172+
selectedDate.isSame(d.timeZone(timeZone), currentMode),
173+
)) !== -1
174+
) {
175+
found = true;
176+
newValue.splice(index, 1);
177+
}
178+
if (!found) {
179+
newValue.push(selectedDate.timeZone(inputTimeZone));
180+
}
181+
this.setValue(newValue);
182+
}
123183
} else {
124184
this.zoomIn();
125185
}
@@ -211,18 +271,18 @@ export function useCalendarState(props: CalendarStateOptions): CalendarState {
211271
return this.isInvalid(next);
212272
},
213273
isSelected(date: DateTime) {
214-
return Boolean(
215-
value &&
216-
date.isSame(value.timeZone(timeZone), currentMode) &&
217-
!this.isCellDisabled(date),
218-
);
219-
},
220-
isCellUnavailable(date: DateTime) {
221-
if (this.mode === minMode) {
222-
return Boolean(props.isDateUnavailable && props.isDateUnavailable(date));
223-
} else {
274+
if (!value || !firstValue || this.isCellDisabled(date)) {
224275
return false;
225276
}
277+
if (selectionMode === 'single') {
278+
return date.isSame(firstValue.timeZone(timeZone), currentMode);
279+
}
280+
281+
const dates = Array.isArray(value) ? value : [value];
282+
return dates.some((d) => date.isSame(d.timeZone(timeZone), currentMode));
283+
},
284+
isCellUnavailable(date: DateTime) {
285+
return props.isDateUnavailable ? props.isDateUnavailable(date) : false;
226286
},
227287
isCellFocused(date: DateTime) {
228288
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 {
@@ -40,7 +40,7 @@ export interface DatePickerProps<T = DateTime>
4040
StyleProps,
4141
AccessibilityProps,
4242
PopupStyleProps {
43-
children?: (props: CalendarProps<T>) => React.ReactNode;
43+
children?: (props: CalendarCommonProps<T>) => React.ReactNode;
4444
disablePortal?: boolean;
4545
disableFocusTrap?: boolean;
4646
}

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

0 commit comments

Comments
 (0)