import React from 'react' import addDays from 'date-fns/add_days' import isBefore from 'date-fns/is_before' import isToday from 'date-fns/is_today' import startOfDay from 'date-fns/start_of_day' import differenceInCalendarMonths from 'date-fns/difference_in_calendar_months' export default function useCalendar({ date = new Date(), offset: userOffset = 0, onDateChange, selected, minDate, maxDate, monthsToDisplay = 1, firstDayOfWeek = 0, showOutsideDays = false, }) { const [resolvedOffset, setOffset] = React.useState(userOffset) const onDateChangeRef = React.useRef() onDateChangeRef.current = onDateChange React.useEffect(() => { setOffset(userOffset) }, [userOffset]) const calendars = React.useMemo( () => getCalendars({ date, selected, monthsToDisplay, minDate, maxDate, offset: resolvedOffset, firstDayOfWeek, showOutsideDays, }), [ date, firstDayOfWeek, maxDate, minDate, monthsToDisplay, resolvedOffset, selected, showOutsideDays, ] ) const getBackProps = React.useCallback( ({ onClick, offset = 1, calendars = requiredProp('getBackProps', 'calendars'), ...rest } = {}) => { return { onClick: composeEventHandlers(onClick, () => { setOffset( resolvedOffset - subtractMonth({ calendars, offset, minDate }) ) }), disabled: isBackDisabled({ calendars, offset, minDate }), 'aria-label': `Go back ${offset} month${offset === 1 ? '' : 's'}`, ...rest, } }, [minDate, resolvedOffset] ) const getForwardProps = React.useCallback( ({ onClick, offset = 1, calendars = requiredProp('getForwardProps', 'calendars'), ...rest } = {}) => { return { onClick: composeEventHandlers(onClick, () => { setOffset(resolvedOffset + addMonth({ calendars, offset, maxDate })) }), disabled: isForwardDisabled({ calendars, offset, maxDate }), 'aria-label': `Go forward ${offset} month${offset === 1 ? '' : 's'}`, ...rest, } }, [maxDate, resolvedOffset] ) const getDateProps = React.useCallback( ( dateObj = requiredProp('getDateProps', 'dateObj'), { onClick, ...rest } = {} ) => { return { onClick: composeEventHandlers(onClick, () => { onDateChangeRef.current(dateObj) }), disabled: !dateObj.selectable, 'aria-label': dateObj.date.toDateString(), 'aria-pressed': dateObj.selected, role: 'button', ...rest, } }, [] ) return { offset: resolvedOffset, calendars, getBackProps, getForwardProps, getDateProps, } } function getCalendars({ date, selected, monthsToDisplay, offset, minDate, maxDate, firstDayOfWeek, showOutsideDays, }) { const months = [] const startDate = getStartDate(date, minDate, maxDate) for (let i = 0; i < monthsToDisplay; i++) { const calendarDates = getMonths({ month: startDate.getMonth() + i + offset, year: startDate.getFullYear(), selectedDates: selected, minDate, maxDate, firstDayOfWeek, showOutsideDays, }) months.push(calendarDates) } return months } function getStartDate(date, minDate, maxDate) { let startDate = startOfDay(date) if (minDate) { const minDateNormalized = startOfDay(minDate) if (isBefore(startDate, minDateNormalized)) { startDate = minDateNormalized } } if (maxDate) { const maxDateNormalized = startOfDay(maxDate) if (isBefore(maxDateNormalized, startDate)) { startDate = maxDateNormalized } } return startDate } function getMonths({ month, year, selectedDates, minDate, maxDate, firstDayOfWeek, showOutsideDays, }) { // Get the normalized month and year, along with days in the month. const daysMonthYear = getNumDaysMonthYear(month, year) const daysInMonth = daysMonthYear.daysInMonth month = daysMonthYear.month year = daysMonthYear.year // Fill out the dates for the month. const dates = [] for (let day = 1; day <= daysInMonth; day++) { const date = new Date(year, month, day) const dateObj = { date, selected: isSelected(selectedDates, date), selectable: isSelectable(minDate, maxDate, date), today: isToday(date), prevMonth: false, nextMonth: false, } dates.push(dateObj) } const firstDayOfMonth = new Date(year, month, 1) const lastDayOfMonth = new Date(year, month, daysInMonth) const frontWeekBuffer = fillFrontWeek({ firstDayOfMonth, minDate, maxDate, selectedDates, firstDayOfWeek, showOutsideDays, }) const backWeekBuffer = fillBackWeek({ lastDayOfMonth, minDate, maxDate, selectedDates, firstDayOfWeek, showOutsideDays, }) dates.unshift(...frontWeekBuffer) dates.push(...backWeekBuffer) // Get the filled out weeks for the // given dates. const weeks = getWeeks(dates) // return the calendar data. return { firstDayOfMonth, lastDayOfMonth, month, year, weeks, } } function getNumDaysMonthYear(month, year) { // If a parameter you specify is outside of the expected range for Month or Day, // JS Date attempts to update the date information in the Date object accordingly! // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/setMonth // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/setDate // Let Date handle the overflow of the month, // which should return the normalized month and year. const normalizedMonthYear = new Date(year, month, 1) month = normalizedMonthYear.getMonth() year = normalizedMonthYear.getFullYear() // Overflow the date to the next month, then subtract the difference // to get the number of days in the previous month. // This will also account for leap years! const daysInMonth = 32 - new Date(year, month, 32).getDate() return { daysInMonth, month, year } } function isSelectable(minDate, maxDate, date) { if ( (minDate && isBefore(date, minDate)) || (maxDate && isBefore(maxDate, date)) ) { return false } return true } function isSelected(selectedDates, date) { selectedDates = Array.isArray(selectedDates) ? selectedDates : [selectedDates] return selectedDates.some(selectedDate => { if ( selectedDate instanceof Date && startOfDay(selectedDate).getTime() === startOfDay(date).getTime() ) { return true } return false }) } function getWeeks(dates) { const weeksLength = Math.ceil(dates.length / 7) const weeks = [] for (let i = 0; i < weeksLength; i++) { weeks[i] = [] for (let x = 0; x < 7; x++) { weeks[i].push(dates[i * 7 + x]) } } return weeks } function fillFrontWeek({ firstDayOfMonth, minDate, maxDate, selectedDates, firstDayOfWeek, showOutsideDays, }) { const dates = [] let firstDay = (firstDayOfMonth.getDay() + 7 - firstDayOfWeek) % 7 if (showOutsideDays) { const lastDayOfPrevMonth = addDays(firstDayOfMonth, -1) const prevDate = lastDayOfPrevMonth.getDate() const prevDateMonth = lastDayOfPrevMonth.getMonth() const prevDateYear = lastDayOfPrevMonth.getFullYear() // Fill out front week for days from // preceding month with dates from previous month. let counter = 0 while (counter < firstDay) { const date = new Date(prevDateYear, prevDateMonth, prevDate - counter) const dateObj = { date, selected: isSelected(selectedDates, date), selectable: isSelectable(minDate, maxDate, date), today: false, prevMonth: true, nextMonth: false, } dates.unshift(dateObj) counter++ } } else { // Fill out front week for days from // preceding month with buffer. while (firstDay > 0) { dates.unshift('') firstDay-- } } return dates } function fillBackWeek({ lastDayOfMonth, minDate, maxDate, selectedDates, firstDayOfWeek, showOutsideDays, }) { const dates = [] let lastDay = (lastDayOfMonth.getDay() + 7 - firstDayOfWeek) % 7 if (showOutsideDays) { const firstDayOfNextMonth = addDays(lastDayOfMonth, 1) const nextDateMonth = firstDayOfNextMonth.getMonth() const nextDateYear = firstDayOfNextMonth.getFullYear() // Fill out back week for days from // following month with dates from next month. let counter = 0 while (counter < 6 - lastDay) { const date = new Date(nextDateYear, nextDateMonth, 1 + counter) const dateObj = { date, selected: isSelected(selectedDates, date), selectable: isSelectable(minDate, maxDate, date), today: false, prevMonth: false, nextMonth: true, } dates.push(dateObj) counter++ } } else { // Fill out back week for days from // following month with buffer. while (lastDay < 6) { dates.push('') lastDay++ } } return dates } function requiredProp(fnName, propName) { throw new Error(`The property "${propName}" is required in "${fnName}"`) } function composeEventHandlers(...fns) { return (event, ...args) => fns.some(fn => { fn && fn(event, ...args) return event.defaultPrevented }) } function isBackDisabled({ calendars, minDate }) { if (!minDate) { return false } const { firstDayOfMonth } = calendars[0] const firstDayOfMonthMinusOne = addDays(firstDayOfMonth, -1) if (isBefore(firstDayOfMonthMinusOne, minDate)) { return true } return false } function isForwardDisabled({ calendars, maxDate }) { if (!maxDate) { return false } const { lastDayOfMonth } = calendars[calendars.length - 1] const lastDayOfMonthPlusOne = addDays(lastDayOfMonth, 1) if (isBefore(maxDate, lastDayOfMonthPlusOne)) { return true } return false } function addMonth({ calendars, offset, maxDate }) { if (offset > 1 && maxDate) { const { lastDayOfMonth } = calendars[calendars.length - 1] const diffInMonths = differenceInCalendarMonths(maxDate, lastDayOfMonth) if (diffInMonths < offset) { offset = diffInMonths } } return offset } function subtractMonth({ calendars, offset, minDate }) { if (offset > 1 && minDate) { const { firstDayOfMonth } = calendars[0] const diffInMonths = differenceInCalendarMonths(firstDayOfMonth, minDate) if (diffInMonths < offset) { offset = diffInMonths } } return offset }