Skip to content

Instantly share code, notes, and snippets.

@tannerlinsley
Created November 10, 2019 02:25
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save tannerlinsley/828afa87e4dfbdca70ae4f71645bf252 to your computer and use it in GitHub Desktop.
Save tannerlinsley/828afa87e4dfbdca70ae4f71645bf252 to your computer and use it in GitHub Desktop.
Calendar Hook
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
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment