@react-stately/calendar
Version:
Spectrum UI components in React
237 lines (208 loc) • 8.67 kB
text/typescript
/*
* Copyright 2020 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
import {alignCenter, constrainValue, isInvalid, previousAvailableDate} from './utils';
import {Calendar, CalendarDate, CalendarIdentifier, DateDuration, GregorianCalendar, isEqualDay, maxDate, minDate, toCalendar, toCalendarDate} from '@internationalized/date';
import {CalendarState, RangeCalendarState} from './types';
import {DateValue, MappedDateValue, RangeCalendarProps} from '@react-types/calendar';
import {RangeValue, ValidationState} from '@react-types/shared';
import {useCalendarState} from './useCalendarState';
import {useControlledState} from '@react-stately/utils';
import {useMemo, useRef, useState} from 'react';
export interface RangeCalendarStateOptions<T extends DateValue = DateValue> extends RangeCalendarProps<T> {
/** The locale to display and edit the value according to. */
locale: string,
/**
* A function that creates a [Calendar](../internationalized/date/Calendar.html)
* object for a given calendar identifier. Such a function may be imported from the
* `@internationalized/date` package, or manually implemented to include support for
* only certain calendars.
*/
createCalendar: (name: CalendarIdentifier) => Calendar,
/**
* The amount of days that will be displayed at once. This affects how pagination works.
* @default {months: 1}
*/
visibleDuration?: DateDuration,
/**
* Determines the alignment of the visible months on initial render based on the current selection or current date if there is no selection.
* @default 'center'
*/
selectionAlignment?: 'start' | 'center' | 'end'
}
/**
* Provides state management for a range calendar component.
* A range calendar displays one or more date grids and allows users to select a contiguous range of dates.
*/
export function useRangeCalendarState<T extends DateValue = DateValue>(props: RangeCalendarStateOptions<T>): RangeCalendarState<T> {
let {
value: valueProp,
defaultValue,
onChange,
createCalendar,
locale,
visibleDuration = {months: 1},
minValue,
maxValue,
...calendarProps} = props;
let [value, setValue] = useControlledState<RangeValue<T> | null, RangeValue<MappedDateValue<T>>>(
valueProp!,
defaultValue || null!,
onChange
);
let [anchorDate, setAnchorDateState] = useState<CalendarDate | null>(null);
let alignment: 'center' | 'start' = 'center';
if (value && value.start && value.end) {
let start = alignCenter(toCalendarDate(value.start), visibleDuration, locale, minValue, maxValue);
let end = start.add(visibleDuration).subtract({days: 1});
if (value.end.compare(end) > 0) {
alignment = 'start';
}
}
// Available range must be stored in a ref so we have access to the updated version immediately in `isInvalid`.
let availableRangeRef = useRef<Partial<RangeValue<DateValue>> | null>(null);
let [availableRange, setAvailableRange] = useState<Partial<RangeValue<DateValue>>|null>(null);
let min = useMemo(() => maxDate(minValue, availableRange?.start), [minValue, availableRange]);
let max = useMemo(() => minDate(maxValue, availableRange?.end), [maxValue, availableRange]);
let calendar = useCalendarState({
...calendarProps,
value: value && value.start,
createCalendar,
locale,
visibleDuration,
minValue: min,
maxValue: max,
selectionAlignment: props.selectionAlignment || alignment
});
let updateAvailableRange = (date) => {
if (date && props.isDateUnavailable && !props.allowsNonContiguousRanges) {
const nextAvailableStartDate = nextUnavailableDate(date, calendar, -1);
const nextAvailableEndDate = nextUnavailableDate(date, calendar, 1);
availableRangeRef.current = {
start: nextAvailableStartDate,
end: nextAvailableEndDate
};
setAvailableRange(availableRangeRef.current);
} else {
availableRangeRef.current = null;
setAvailableRange(null);
}
};
// If the visible range changes, we need to update the available range.
let [lastVisibleRange, setLastVisibleRange] = useState(calendar.visibleRange);
if (!isEqualDay(calendar.visibleRange.start, lastVisibleRange.start) || !isEqualDay(calendar.visibleRange.end, lastVisibleRange.end)) {
updateAvailableRange(anchorDate);
setLastVisibleRange(calendar.visibleRange);
}
let setAnchorDate = (date: CalendarDate | null) => {
if (date) {
setAnchorDateState(date);
updateAvailableRange(date);
} else {
setAnchorDateState(null);
updateAvailableRange(null);
}
};
let highlightedRange = anchorDate ? makeRange(anchorDate, calendar.focusedDate) : value && makeRange(value.start, value.end);
let selectDate = (date: CalendarDate) => {
if (props.isReadOnly) {
return;
}
const constrainedDate = constrainValue(date, min, max);
const previousAvailableConstrainedDate = previousAvailableDate(constrainedDate, calendar.visibleRange.start, props.isDateUnavailable);
if (!previousAvailableConstrainedDate) {
return;
}
if (!anchorDate) {
setAnchorDate(previousAvailableConstrainedDate);
} else {
let range = makeRange(anchorDate, previousAvailableConstrainedDate);
if (range) {
setValue({
start: convertValue(range.start, value?.start) as T,
end: convertValue(range.end, value?.end) as T
});
}
setAnchorDate(null);
}
};
let [isDragging, setDragging] = useState(false);
let {isDateUnavailable} = props;
let isInvalidSelection = useMemo(() => {
if (!value || anchorDate) {
return false;
}
if (isDateUnavailable && (isDateUnavailable(value.start) || isDateUnavailable(value.end))) {
return true;
}
return isInvalid(value.start, minValue, maxValue) || isInvalid(value.end, minValue, maxValue);
}, [isDateUnavailable, value, anchorDate, minValue, maxValue]);
let isValueInvalid = props.isInvalid || props.validationState === 'invalid' || isInvalidSelection;
let validationState: ValidationState | null = isValueInvalid ? 'invalid' : null;
return {
...calendar,
value,
setValue,
anchorDate,
setAnchorDate,
highlightedRange,
validationState,
isValueInvalid,
selectFocusedDate() {
selectDate(calendar.focusedDate);
},
selectDate,
highlightDate(date) {
if (anchorDate) {
calendar.setFocusedDate(date);
}
},
isSelected(date) {
return Boolean(highlightedRange && date.compare(highlightedRange.start) >= 0 && date.compare(highlightedRange.end) <= 0 && !calendar.isCellDisabled(date) && !calendar.isCellUnavailable(date));
},
isInvalid(date) {
return calendar.isInvalid(date) || isInvalid(date, availableRangeRef.current?.start, availableRangeRef.current?.end);
},
isDragging,
setDragging
};
}
function makeRange(start: DateValue, end: DateValue): RangeValue<CalendarDate> | null {
if (!start || !end) {
return null;
}
if (end.compare(start) < 0) {
[start, end] = [end, start];
}
return {start: toCalendarDate(start), end: toCalendarDate(end)};
}
function convertValue(newValue: CalendarDate, oldValue?: DateValue): DateValue {
// The display calendar should not have any effect on the emitted value.
// Emit dates in the same calendar as the original value, if any, otherwise gregorian.
newValue = toCalendar(newValue, oldValue?.calendar || new GregorianCalendar());
// Preserve time if the input value had one.
if (oldValue && 'hour' in oldValue) {
return oldValue.set(newValue);
}
return newValue;
}
function nextUnavailableDate(anchorDate: CalendarDate, state: CalendarState, dir: number): CalendarDate | undefined {
let nextDate = anchorDate.add({days: dir});
while (
(dir < 0 ? nextDate.compare(state.visibleRange.start) >= 0 : nextDate.compare(state.visibleRange.end) <= 0) &&
!state.isCellUnavailable(nextDate)
) {
nextDate = nextDate.add({days: dir});
}
if (state.isCellUnavailable(nextDate)) {
return nextDate.add({days: -dir});
}
}