Skip to content

Instantly share code, notes, and snippets.

@tannerlinsley
Created November 10, 2019 02:25
Show Gist options
  • Save tannerlinsley/828afa87e4dfbdca70ae4f71645bf252 to your computer and use it in GitHub Desktop.
Save tannerlinsley/828afa87e4dfbdca70ae4f71645bf252 to your computer and use it in GitHub Desktop.

Revisions

  1. tannerlinsley created this gist Nov 10, 2019.
    429 changes: 429 additions & 0 deletions useCalendar.js
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,429 @@
    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
    }