UNPKG

@react-stately/calendar

Version:
374 lines (349 loc) • 13.3 kB
/* * 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, alignEnd, alignStart, constrainStart, constrainValue, isInvalid, previousAvailableDate} from './utils'; import { Calendar, CalendarDate, CalendarIdentifier, DateDuration, DateFormatter, endOfMonth, endOfWeek, getDayOfWeek, GregorianCalendar, isEqualCalendar, isSameDay, startOfMonth, startOfWeek, toCalendar, toCalendarDate, today } from '@internationalized/date'; import {CalendarProps, DateValue, MappedDateValue} from '@react-types/calendar'; import {CalendarState} from './types'; import {useControlledState} from '@react-stately/utils'; import {useMemo, useState} from 'react'; import {ValidationState} from '@react-types/shared'; export interface CalendarStateOptions<T extends DateValue = DateValue> extends CalendarProps<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 calendar component. * A calendar displays one or more date grids and allows users to select a single date. */ export function useCalendarState<T extends DateValue = DateValue>(props: CalendarStateOptions<T>): CalendarState { let defaultFormatter = useMemo(() => new DateFormatter(props.locale), [props.locale]); let resolvedOptions = useMemo(() => defaultFormatter.resolvedOptions(), [defaultFormatter]); let { locale, createCalendar, visibleDuration = {months: 1}, minValue, maxValue, selectionAlignment, isDateUnavailable, pageBehavior = 'visible', firstDayOfWeek } = props; let calendar = useMemo(() => createCalendar(resolvedOptions.calendar as CalendarIdentifier), [createCalendar, resolvedOptions.calendar]); let [value, setControlledValue] = useControlledState<DateValue | null, MappedDateValue<T>>(props.value!, props.defaultValue ?? null!, props.onChange); let calendarDateValue = useMemo(() => value ? toCalendar(toCalendarDate(value), calendar) : null, [value, calendar]); let timeZone = useMemo(() => value && 'timeZone' in value ? value.timeZone : resolvedOptions.timeZone, [value, resolvedOptions.timeZone]); let focusedCalendarDate = useMemo(() => ( props.focusedValue ? constrainValue(toCalendar(toCalendarDate(props.focusedValue), calendar), minValue, maxValue) : undefined ), [props.focusedValue, calendar, minValue, maxValue]); let defaultFocusedCalendarDate = useMemo(() => ( constrainValue( props.defaultFocusedValue ? toCalendar(toCalendarDate(props.defaultFocusedValue), calendar) : calendarDateValue || toCalendar(today(timeZone), calendar), minValue, maxValue ) ), [props.defaultFocusedValue, calendarDateValue, timeZone, calendar, minValue, maxValue]); let [focusedDate, setFocusedDate] = useControlledState(focusedCalendarDate, defaultFocusedCalendarDate, props.onFocusChange); let [startDate, setStartDate] = useState(() => { switch (selectionAlignment) { case 'start': return alignStart(focusedDate, visibleDuration, locale, minValue, maxValue); case 'end': return alignEnd(focusedDate, visibleDuration, locale, minValue, maxValue); case 'center': default: return alignCenter(focusedDate, visibleDuration, locale, minValue, maxValue); } }); let [isFocused, setFocused] = useState(props.autoFocus || false); let endDate = useMemo(() => { let duration = {...visibleDuration}; if (duration.days) { duration.days--; } else { duration.days = -1; } return startDate.add(duration); }, [startDate, visibleDuration]); // Reset focused date and visible range when calendar changes. let [lastCalendar, setLastCalendar] = useState(calendar); if (!isEqualCalendar(calendar, lastCalendar)) { let newFocusedDate = toCalendar(focusedDate, calendar); setStartDate(alignCenter(newFocusedDate, visibleDuration, locale, minValue, maxValue)); setFocusedDate(newFocusedDate); setLastCalendar(calendar); } if (isInvalid(focusedDate, minValue, maxValue)) { // If the focused date was moved to an invalid value, it can't be focused, so constrain it. setFocusedDate(constrainValue(focusedDate, minValue, maxValue)); } else if (focusedDate.compare(startDate) < 0) { setStartDate(alignEnd(focusedDate, visibleDuration, locale, minValue, maxValue)); } else if (focusedDate.compare(endDate) > 0) { setStartDate(alignStart(focusedDate, visibleDuration, locale, minValue, maxValue)); } // Sets focus to a specific cell date function focusCell(date: CalendarDate) { date = constrainValue(date, minValue, maxValue); setFocusedDate(date); } function setValue(newValue: CalendarDate | null) { if (!props.isDisabled && !props.isReadOnly) { let localValue = newValue; if (localValue === null) { setControlledValue(null); return; } localValue = constrainValue(localValue, minValue, maxValue); localValue = previousAvailableDate(localValue, startDate, isDateUnavailable); if (!localValue) { return; } // 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. localValue = toCalendar(localValue, value?.calendar || new GregorianCalendar()); // Preserve time if the input value had one. if (value && 'hour' in value) { setControlledValue(value.set(localValue)); } else { setControlledValue(localValue); } } } let isUnavailable = useMemo(() => { if (!calendarDateValue) { return false; } if (isDateUnavailable && isDateUnavailable(calendarDateValue)) { return true; } return isInvalid(calendarDateValue, minValue, maxValue); }, [calendarDateValue, isDateUnavailable, minValue, maxValue]); let isValueInvalid = props.isInvalid || props.validationState === 'invalid' || isUnavailable; let validationState: ValidationState | null = isValueInvalid ? 'invalid' : null; let pageDuration = useMemo(() => { if (pageBehavior === 'visible') { return visibleDuration; } return unitDuration(visibleDuration); }, [pageBehavior, visibleDuration]); return { isDisabled: props.isDisabled ?? false, isReadOnly: props.isReadOnly ?? false, value: calendarDateValue, setValue, visibleRange: { start: startDate, end: endDate }, minValue, maxValue, focusedDate, timeZone, validationState, isValueInvalid, setFocusedDate(date) { focusCell(date); setFocused(true); }, focusNextDay() { focusCell(focusedDate.add({days: 1})); }, focusPreviousDay() { focusCell(focusedDate.subtract({days: 1})); }, focusNextRow() { if (visibleDuration.days) { this.focusNextPage(); } else if (visibleDuration.weeks || visibleDuration.months || visibleDuration.years) { focusCell(focusedDate.add({weeks: 1})); } }, focusPreviousRow() { if (visibleDuration.days) { this.focusPreviousPage(); } else if (visibleDuration.weeks || visibleDuration.months || visibleDuration.years) { focusCell(focusedDate.subtract({weeks: 1})); } }, focusNextPage() { let start = startDate.add(pageDuration); setFocusedDate(constrainValue(focusedDate.add(pageDuration), minValue, maxValue)); setStartDate( alignStart( constrainStart(focusedDate, start, pageDuration, locale, minValue, maxValue), pageDuration, locale ) ); }, focusPreviousPage() { let start = startDate.subtract(pageDuration); setFocusedDate(constrainValue(focusedDate.subtract(pageDuration), minValue, maxValue)); setStartDate( alignStart( constrainStart(focusedDate, start, pageDuration, locale, minValue, maxValue), pageDuration, locale ) ); }, focusSectionStart() { if (visibleDuration.days) { focusCell(startDate); } else if (visibleDuration.weeks) { focusCell(startOfWeek(focusedDate, locale)); } else if (visibleDuration.months || visibleDuration.years) { focusCell(startOfMonth(focusedDate)); } }, focusSectionEnd() { if (visibleDuration.days) { focusCell(endDate); } else if (visibleDuration.weeks) { focusCell(endOfWeek(focusedDate, locale)); } else if (visibleDuration.months || visibleDuration.years) { focusCell(endOfMonth(focusedDate)); } }, focusNextSection(larger) { if (!larger && !visibleDuration.days) { focusCell(focusedDate.add(unitDuration(visibleDuration))); return; } if (visibleDuration.days) { this.focusNextPage(); } else if (visibleDuration.weeks) { focusCell(focusedDate.add({months: 1})); } else if (visibleDuration.months || visibleDuration.years) { focusCell(focusedDate.add({years: 1})); } }, focusPreviousSection(larger) { if (!larger && !visibleDuration.days) { focusCell(focusedDate.subtract(unitDuration(visibleDuration))); return; } if (visibleDuration.days) { this.focusPreviousPage(); } else if (visibleDuration.weeks) { focusCell(focusedDate.subtract({months: 1})); } else if (visibleDuration.months || visibleDuration.years) { focusCell(focusedDate.subtract({years: 1})); } }, selectFocusedDate() { if (!(isDateUnavailable && isDateUnavailable(focusedDate))) { setValue(focusedDate); } }, selectDate(date) { setValue(date); }, isFocused, setFocused, isInvalid(date) { return isInvalid(date, minValue, maxValue); }, isSelected(date) { return calendarDateValue != null && isSameDay(date, calendarDateValue) && !this.isCellDisabled(date) && !this.isCellUnavailable(date); }, isCellFocused(date) { return isFocused && focusedDate && isSameDay(date, focusedDate); }, isCellDisabled(date) { return props.isDisabled || date.compare(startDate) < 0 || date.compare(endDate) > 0 || this.isInvalid(date); }, isCellUnavailable(date) { return props.isDateUnavailable ? props.isDateUnavailable(date) : false; }, isPreviousVisibleRangeInvalid() { let prev = startDate.subtract({days: 1}); return isSameDay(prev, startDate) || this.isInvalid(prev); }, isNextVisibleRangeInvalid() { // Adding may return the same date if we reached the end of time // according to the calendar system (e.g. 9999-12-31). let next = endDate.add({days: 1}); return isSameDay(next, endDate) || this.isInvalid(next); }, getDatesInWeek(weekIndex, from = startDate) { let date = from.add({weeks: weekIndex}); let dates: (CalendarDate | null)[] = []; date = startOfWeek(date, locale, firstDayOfWeek); // startOfWeek will clamp dates within the calendar system's valid range, which may // start in the middle of a week. In this case, add null placeholders. let dayOfWeek = getDayOfWeek(date, locale, firstDayOfWeek); for (let i = 0; i < dayOfWeek; i++) { dates.push(null); } while (dates.length < 7) { dates.push(date); let nextDate = date.add({days: 1}); if (isSameDay(date, nextDate)) { // If the next day is the same, we have hit the end of the calendar system. break; } date = nextDate; } // Add null placeholders if at the end of the calendar system. while (dates.length < 7) { dates.push(null); } return dates; } }; } function unitDuration(duration: DateDuration) { let unit = {...duration}; for (let key in duration) { unit[key] = 1; } return unit; }