diff --git a/src/components/Calendar/Calendar.tsx b/src/components/Calendar/Calendar.tsx index 1c800cc..2af5d55 100644 --- a/src/components/Calendar/Calendar.tsx +++ b/src/components/Calendar/Calendar.tsx @@ -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 +export interface CalendarCommonProps extends CalendarStateOptions, DomProps, StyleProps, @@ -24,6 +25,12 @@ export interface CalendarProps */ size?: CalendarSize; } + +export interface CalendarProps + extends CalendarCommonProps> { + selectionMode?: M; +} + export const Calendar = React.forwardRef(function Calendar( props: CalendarProps, ref, @@ -31,4 +38,6 @@ export const Calendar = React.forwardRef(functi const state = useCalendarState(props); return ; -}); +}) as ( + props: CalendarProps & React.RefAttributes, +) => React.ReactNode; diff --git a/src/components/Calendar/README.md b/src/components/Calendar/README.md index 12e6556..ef4d5ff 100644 --- a/src/components/Calendar/README.md +++ b/src/components/Calendar/README.md @@ -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`. +## 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. + + + + + +```tsx + +``` + + + ## 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) @@ -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>` | `{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) => void)` | | | onFocus | Fires when the control gets focus. Provides focus event as a callback's argument | `((e: FocusEvent) => void)` | | @@ -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` | | diff --git a/src/components/Calendar/__stories__/Calendar.stories.tsx b/src/components/Calendar/__stories__/Calendar.stories.tsx index 9ce4bb8..5835ccc 100644 --- a/src/components/Calendar/__stories__/Calendar.stories.tsx +++ b/src/components/Calendar/__stories__/Calendar.stories.tsx @@ -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, @@ -53,7 +53,7 @@ export const Default = meta.story({ theme: 'success', content: (
-
date: {resArray.map((r) => r.format()).join(', ')}
+
date: {resArray.map((r) => r.format()).join(', ') || `[]`}
), }); @@ -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(null); + return ( + { + if (v.isSame(value, 'day')) { + setValue(null); + } else { + setValue(v); + } + props.onUpdate?.(v); + }} + /> + ); + }, + parameters: { + controls: {exclude: ['selectionMode', 'value', 'defaultValue']}, + }, +}); diff --git a/src/components/CalendarView/hooks/types.ts b/src/components/CalendarView/hooks/types.ts index c389068..cf27a56 100644 --- a/src/components/CalendarView/hooks/types.ts +++ b/src/components/CalendarView/hooks/types.ts @@ -117,11 +117,16 @@ interface CalendarStateBase { readonly endDate: DateTime; } -export interface CalendarState extends CalendarStateBase { +export type SelectionMode = 'single' | 'multiple'; +export type CalendarValueType = M extends 'single' + ? DateTime | null + : DateTime[]; + +export interface CalendarState extends CalendarStateBase { /** The currently selected date. */ - readonly value: DateTime | null; + readonly value: CalendarValueType; /** Sets the currently selected date. */ - setValue: (value: DateTime) => void; + setValue: (value: DateTime | DateTime[] | null) => void; } export interface RangeCalendarState extends CalendarStateBase { diff --git a/src/components/CalendarView/hooks/useCalendarState.ts b/src/components/CalendarView/hooks/useCalendarState.ts index 2c6fa3c..6afbc18 100644 --- a/src/components/CalendarView/hooks/useCalendarState.ts +++ b/src/components/CalendarView/hooks/useCalendarState.ts @@ -9,10 +9,16 @@ import {constrainValue, createPlaceholderValue, isWeekend, mergeDateTime} from ' 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 - extends ValueBase, + extends ValueBase>, CalendarStateOptionsBase {} export type {CalendarState} from './types'; @@ -23,12 +29,14 @@ const defaultModes: Record = { quarters: false, years: true, }; -export function useCalendarState(props: CalendarStateOptions): CalendarState { - const {disabled, readOnly, modes = defaultModes} = props; - const [value, setValue] = useControlledState( +export function useCalendarState( + props: CalendarStateOptions> & {selectionMode?: M}, +): CalendarState { + const {disabled, readOnly, modes = defaultModes, selectionMode = 'single' as M} = props; + const [value, setValue] = useControlledState( props.value, - props.defaultValue ?? null, - props.onUpdate, + props.defaultValue ?? (selectionMode === 'single' ? null : []), + props.onUpdate as any, ); const availableModes = calendarLayouts.filter((l) => modes[l]); const minMode = availableModes[0] || 'days'; @@ -40,9 +48,10 @@ export function useCalendarState(props: CalendarStateOptions): CalendarState { ); 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; @@ -65,10 +74,11 @@ export function useCalendarState(props: CalendarStateOptions): CalendarState { 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, @@ -95,31 +105,81 @@ export function useCalendarState(props: CalendarStateOptions): CalendarState { const startDate = getStartDate(focusedDate, currentMode); const endDate = getEndDate(focusedDate, currentMode); + const finalValue = + selectionMode === 'single' + ? firstValue + : Array.isArray(value) + ? value + : value + ? [value] + : []; + return { disabled, readOnly, - value, - setValue(date: DateTime) { + value: finalValue as CalendarValueType, + 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(); } @@ -211,18 +271,18 @@ export function useCalendarState(props: CalendarStateOptions): CalendarState { 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); diff --git a/src/components/DatePicker/DatePicker.tsx b/src/components/DatePicker/DatePicker.tsx index 51e34b7..ebe7186 100644 --- a/src/components/DatePicker/DatePicker.tsx +++ b/src/components/DatePicker/DatePicker.tsx @@ -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 { @@ -40,7 +40,7 @@ export interface DatePickerProps StyleProps, AccessibilityProps, PopupStyleProps { - children?: (props: CalendarProps) => React.ReactNode; + children?: (props: CalendarCommonProps) => React.ReactNode; disablePortal?: boolean; disableFocusTrap?: boolean; } diff --git a/src/components/DatePicker/hooks/useDatePickerProps.ts b/src/components/DatePicker/hooks/useDatePickerProps.ts index 8a54ee6..0894391 100644 --- a/src/components/DatePicker/hooks/useDatePickerProps.ts +++ b/src/components/DatePicker/hooks/useDatePickerProps.ts @@ -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'; @@ -24,7 +24,7 @@ interface InnerDatePickerProps { fieldProps: TextInputProps; calendarButtonProps: ButtonButtonProps & {ref: React.Ref}; popupProps: PopupProps; - calendarProps: CalendarProps & {ref: React.Ref}; + calendarProps: CalendarCommonProps & {ref: React.Ref}; timeInputProps: DateFieldProps; } diff --git a/src/components/RangeCalendar/RangeCalendar.tsx b/src/components/RangeCalendar/RangeCalendar.tsx index 6f97313..2585f39 100644 --- a/src/components/RangeCalendar/RangeCalendar.tsx +++ b/src/components/RangeCalendar/RangeCalendar.tsx @@ -4,7 +4,7 @@ import React from 'react'; import type {DateTime} from '@gravity-ui/date-utils'; -import type {CalendarProps} from '../Calendar/Calendar'; +import type {CalendarCommonProps} from '../Calendar/Calendar'; import {CalendarView} from '../CalendarView/CalendarView'; import type {CalendarInstance} from '../CalendarView/CalendarView'; import {useRangeCalendarState} from '../CalendarView/hooks/useRangeCalendarState'; @@ -12,7 +12,7 @@ import type {RangeValue} from '../types'; import '../CalendarView/Calendar.scss'; -export type RangeCalendarProps = CalendarProps>; +export type RangeCalendarProps = CalendarCommonProps>; export const RangeCalendar = React.forwardRef( function Calendar(props: RangeCalendarProps, ref) { diff --git a/src/components/RelativeDatePicker/hooks/useRelativeDatePickerProps.ts b/src/components/RelativeDatePicker/hooks/useRelativeDatePickerProps.ts index 63fa818..f8b6a6a 100644 --- a/src/components/RelativeDatePicker/hooks/useRelativeDatePickerProps.ts +++ b/src/components/RelativeDatePicker/hooks/useRelativeDatePickerProps.ts @@ -22,7 +22,7 @@ interface InnerRelativeDatePickerProps { modeSwitcherProps: ButtonButtonProps; calendarButtonProps: ButtonButtonProps; popupProps: PopupProps; - calendarProps: React.ComponentProps; + calendarProps: React.ComponentProps>; timeInputProps: DateFieldProps; }