Calendars
Calendar UI components
Calendar UI to view schedule
App.tsx
Calendar.tsx
CalendarDay.tsx
CalendarDayLabel.tsx
CalendarHeader.tsx
CalendarHeaderItem.tsx
CalendarWeek.tsx
types.ts
useCalendar.ts
useCore.ts
import { Box, Button, Flex, Text } from '@chakra-ui/react'
import dayjs from 'dayjs'
import { Calendar } from './Calendar'
import { CalendarDay } from './CalendarDay'
import { CalendarDayLabel } from './CalendarDayLabel'
import { CalendarHeader } from './CalendarHeader'
import { CalendarHeaderItem } from './CalendarHeaderItem'
import { CalendarWeek } from './CalendarWeek'
import { useCalendar } from './useCalendar'
export function App() {
const { items, headerItems, goToNextMonth, goToPrevMonth, currentDate } =
useCalendar()
return (
<Flex flex={1} flexDirection="column">
<Flex
alignItems="center"
justifyContent="center"
w={{ base: '100%', md: '50%' }}
mx="auto"
h="72px"
py={4}
>
<Button onClick={goToPrevMonth}>Prev</Button>
<Text
mx={10}
flex={{ base: 1, lg: 'none' }}
w={{ base: 'auto', lg: '140px' }}
fontWeight="semibold"
textAlign="center"
>
{dayjs(currentDate).format('MMMM YYYY')}
</Text>
<Button onClick={goToNextMonth}>Next</Button>
</Flex>
<Calendar>
<CalendarHeader>
{headerItems.map((h, i) => (
<CalendarHeaderItem
key={i}
isSaturday={h.isSaturday}
isSunday={h.isSunday}
>
{h.weekday}
</CalendarHeaderItem>
))}
</CalendarHeader>
<Box>
{items.map((week, i) => (
<CalendarWeek key={i}>
{week.map((day) => (
<CalendarDay
key={day.date}
isSunday={day.isSunday}
isSaturday={day.isSaturday}
isHoliday={day.isHoliday}
isDisabled={!day.isThisMonth}
isToday={day.isToday}
>
<CalendarDayLabel
isSunday={day.isSunday}
isSaturday={day.isSaturday}
isHoliday={day.isHoliday}
isDisabled={!day.isThisMonth}
>
{day.calendarDate}
</CalendarDayLabel>
</CalendarDay>
))}
</CalendarWeek>
))}
</Box>
</Calendar>
</Flex>
)
}
import { Flex } from '@chakra-ui/react'
import type { PropsWithChildren } from 'react'
export function Calendar({ children }: PropsWithChildren) {
return (
<Flex
flex={1}
flexDirection="column"
bg="transparent"
borderTopWidth={{ base: 'none', md: '1px' }}
borderLeftWidth={{ base: 'none', md: '1px' }}
borderStyle="solid"
borderTopColor={{ base: 'transparent', md: 'gray.200' }}
borderLeftColor={{ base: 'transparent', md: 'gray.200' }}
_dark={{
borderTopColor: { base: 'none', md: 'gray.700' },
borderLeftColor: { base: 'none', md: 'gray.700' },
}}
>
{children}
</Flex>
)
}
import type { FlexProps } from '@chakra-ui/react'
import { Flex } from '@chakra-ui/react'
import type { PropsWithChildren } from 'react'
import { useMemo } from 'react'
const variants = {
today: {
borderLeftWidth: { base: 'none', md: '1px' },
borderTopWidth: { base: 'none', md: '1px' },
borderColor: { base: 'none', md: 'teal.400' },
_dark: {
borderColor: { base: 'none', md: 'teal.400' },
},
},
disabled: {
bg: { base: 'none', md: 'gray.50' },
color: { base: 'gray.100', md: 'inherit' },
_dark: {
bg: { base: 'none', md: 'transparent' },
},
},
saturday: {},
sunday: {},
holiday: {},
base: {
bg: 'transparent',
},
} as const
type Variant = keyof typeof variants
type Props = {
isDisabled?: boolean
isSaturday?: boolean
isSunday?: boolean
isHoliday?: boolean
isToday?: boolean
}
export function CalendarDay({
children,
isSunday,
isSaturday,
isDisabled,
isHoliday,
isToday,
}: PropsWithChildren<Props>) {
const variant = useMemo((): Variant => {
if (isDisabled) return 'disabled'
if (isToday) return 'today'
if (isSunday) return 'sunday'
if (isSaturday) return 'saturday'
if (isHoliday) return 'holiday'
return 'base'
}, [isDisabled, isHoliday, isSaturday, isSunday, isToday])
const variantStyle = variants[variant] as FlexProps
return (
<Flex
flex={1}
overflow="hidden"
userSelect="none"
position="relative"
padding={0}
minWidth={0}
alignItems="center"
flexDirection="column"
minH={{ base: '50px', md: '100px', lg: '130px' }}
maxH={{ base: '50px', md: '100px', lg: '130px' }}
borderStyle="solid"
borderRightWidth={{ base: 'none', md: '1px' }}
borderBottomWidth={{ base: 'none', md: '1px' }}
borderRightColor={{ base: 'none', md: 'gray.200' }}
borderBottomColor={{ base: 'none', md: 'gray.200' }}
{...variantStyle}
_dark={{
borderRightColor: { base: 'none', md: 'gray.700' },
borderBottomColor: { base: 'none', md: 'gray.700' },
bg: 'rgba(16, 20, 24, 0.7)',
...variantStyle?._dark,
}}
>
{children}
</Flex>
)
}
import { Flex } from '@chakra-ui/react'
import type { PropsWithChildren } from 'react'
import { useMemo } from 'react'
const variants = {
disabled: {
color: 'gray.400',
},
today: {
color: 'teal.400',
_dark: {
color: 'teal.600',
},
},
saturday: {
color: 'cyan.500',
_dark: {
color: 'cyan.500',
},
},
sunday: {
color: 'red.500',
_dark: {
color: 'red.300',
},
},
holiday: {
color: 'red.500',
_dark: {
color: 'red.300',
},
},
base: {
color: 'inherit',
},
} as const
type Variant = keyof typeof variants
type Props = {
isDisabled?: boolean
isSaturday?: boolean
isSunday?: boolean
isHoliday?: boolean
isToday?: boolean
}
export function CalendarDayLabel({
children,
isSunday,
isSaturday,
isDisabled,
isToday,
isHoliday,
}: PropsWithChildren<Props>) {
const variant = useMemo((): NonNullable<Variant> => {
if (isDisabled) return 'disabled'
if (isSunday) return 'sunday'
if (isSaturday) return 'saturday'
if (isHoliday) return 'holiday'
if (isToday) return 'today'
return 'base'
}, [isDisabled, isHoliday, isSaturday, isSunday, isToday])
const variantStyle = variants[variant]
return (
<Flex
position={'relative'}
pt={{ base: 0, md: 1 }}
textAlign="center"
fontWeight="normal"
h="28px"
{...variantStyle}
>
{children}
</Flex>
)
}
import { Flex } from '@chakra-ui/react'
import type { PropsWithChildren } from 'react'
export function CalendarHeader({ children }: PropsWithChildren) {
return (
<Flex flex={1} userSelect="none" minH={{ base: '30px', md: '30px' }}>
{children}
</Flex>
)
}
import type { FlexProps } from '@chakra-ui/react'
import { Flex } from '@chakra-ui/react'
import type { PropsWithChildren } from 'react'
import { useMemo } from 'react'
const variants = {
base: {
color: 'gray.500',
},
saturday: {
color: 'cyan.500',
},
sunday: {
color: 'red.500',
_dark: {
color: 'red.300',
},
},
} as const
type Variant = keyof typeof variants
type Props = {
isSaturday?: boolean
isSunday?: boolean
}
export function CalendarHeaderItem({
children,
isSunday,
isSaturday,
}: PropsWithChildren<Props>) {
const variant = useMemo((): NonNullable<Variant> => {
if (isSunday) return 'sunday'
if (isSaturday) return 'saturday'
return 'base'
}, [isSaturday, isSunday])
const variantStyle = variants[variant] as FlexProps
return (
<Flex
fontWeight="semibold"
fontSize={{ base: 'xs', md: 'sm' }}
p={1}
flex="1 0 20px"
userSelect="none"
overflow="hidden"
textAlign="center"
justifyContent="center"
textOverflow="ellipsis"
whiteSpace="nowrap"
bg="transparent"
borderRightWidth={{ base: 'none', md: '1px' }}
borderBottomWidth={{ base: 'none', md: '1px' }}
borderRightColor="gray.200"
borderBottomColor="gray.200"
borderStyle="solid"
{...variantStyle}
_dark={{
borderRightColor: { base: 'none', md: 'gray.700' },
borderBottomColor: { base: 'none', md: 'gray.700' },
...variantStyle?._dark,
}}
>
{children}
</Flex>
)
}
import { Flex } from '@chakra-ui/react'
import type { PropsWithChildren } from 'react'
export function CalendarWeek({ children }: PropsWithChildren) {
return (
<Flex
flex={1}
my={{ base: '2.5px', md: 0 }}
h={{ base: '50px', md: 'auto' }}
minH={{ base: '50px', md: '100px', lg: '130px' }}
maxH={{ base: '50px', md: '100px', lg: '130px' }}
maxW={{ base: '100%' }}
>
{children}
</Flex>
)
}
export type CalendarItem = {
calendarDate: string
date: string
isHoliday: boolean
isSaturday: boolean
isSunday: boolean
isToday: boolean
isThisMonth: boolean
}
export type CalendarItems = CalendarItem[][]
export type CalendarHeaderItem = {
weekday: string
isSaturday: boolean
isSunday: boolean
isThisMonth: boolean
}
import dayjs from 'dayjs'
import type { Dayjs } from 'dayjs'
import { useState, useMemo, useEffect } from 'react'
import { useCore } from './useCore'
export const useCalendar = (initialDate?: string) => {
const { getHeaderItems, getItems } = useCore()
const [currentDate, setCurrentDate] = useState<Dayjs>(
initialDate ? dayjs(initialDate) : dayjs(),
)
useEffect(() => {
setCurrentDate(dayjs(initialDate))
}, [initialDate])
const items = getItems(currentDate)
const headerItems = getHeaderItems(currentDate)
const goToPrevMonth = () => {
const newDate = currentDate.subtract(1, 'month')
setCurrentDate(newDate)
return newDate.format('YYYY-MM-DD')
}
const goToNextMonth = () => {
const newDate = currentDate.add(1, 'month')
setCurrentDate(newDate)
return newDate.format('YYYY-MM-DD')
}
const goToToday = () => {
const newDate = dayjs()
setCurrentDate(newDate)
return newDate.format('YYYY-MM-DD')
}
return {
currentDate: useMemo(() => currentDate.format('YYYY-MM-DD'), [currentDate]),
items,
headerItems,
goToPrevMonth,
goToNextMonth,
goToToday,
}
}
import holidayJp from '@holiday-jp/holiday_jp'
import dayjs from 'dayjs'
import type { Dayjs } from 'dayjs'
import updateLocale from 'dayjs/plugin/updateLocale'
import type { CalendarItems, CalendarHeaderItem } from './types'
dayjs.extend(updateLocale)
dayjs.updateLocale('en', {
weekdaysShort: ['S', 'M', 'T', 'W', 'T', 'F', 'S'],
})
/**
* Returns a list of holidays between the start and end dates.
* Apply your country's holidays here.
*
* @param {string|Dayjs} date - The date for which the holidays are to be fetched.
* Can be a string in the format 'YYYY-MM-DD' or a Dayjs instance.
* @returns {Array} The list of holidays between the start and end dates.
*/
const holidays = (date: string | Dayjs) => {
const currentDate = dayjs(date)
return holidayJp.between(
new Date(currentDate.startOf('month').format('YYYY-MM-DD')),
new Date(currentDate.add(1, 'month').endOf('month').format('YYYY-MM-DD')),
)
}
const isJapaneseHoliday = (date: Dayjs): boolean => {
return holidays(date).some((holiday) =>
dayjs(holiday.date).isSame(date, 'day'),
)
}
export const useCore = () => {
/**
* Retrieve calendar items for a given date.
*
* @param {string | Dayjs} date - The date to retrieve calendar items for.
* @returns {CalendarItems} - The calendar items for the given date.
*/
const getItems = (date: string | Dayjs): CalendarItems => {
const res: CalendarItems = []
const today = dayjs()
const currentDate = dayjs(date)
const startOfMonth = currentDate.startOf('month')
const endOfMonth = currentDate.endOf('month')
const startDate = startOfMonth.startOf('week')
const endDate = endOfMonth.endOf('week')
let current = startDate
let days = []
while (current.isBefore(endDate) || current.isSame(endDate, 'day')) {
const isEndOfWeek = current.day() === 6
days.push({
calendarDate: current.format('D'),
date: current.format('YYYY-MM-DD'),
isHoliday: isJapaneseHoliday(current),
isSaturday: current.day() === 6,
isSunday: current.day() === 0,
isToday: current.isSame(today, 'day'),
isThisMonth: current.isSame(currentDate, 'month'),
})
if (isEndOfWeek) {
res.push(days)
days = []
}
current = current.add(1, 'day')
}
return res
}
/**
* Returns an array of calendar items grouped by week.
*
* @param {string | Dayjs} date - The date to determine the week from.
* @returns {CalendarItems} - An array of calendar items grouped by week.
*/
const getWeekItems = (date: string | Dayjs): CalendarItems => {
const res: CalendarItems = []
const today = dayjs()
const startOfWeek = dayjs(date).startOf('week')
const endOfWeek = dayjs(date).endOf('week')
let current = startOfWeek
let days = []
while (current.isBefore(endOfWeek) || current.isSame(endOfWeek, 'day')) {
const isEndOfWeek = current.day() === 6
days.push({
calendarDate: current.format('D'),
date: current.format('YYYY-MM-DD'),
isHoliday: isJapaneseHoliday(current),
isSaturday: current.day() === 6,
isSunday: current.day() === 0,
isToday: current.isSame(today, 'day'),
isThisMonth: current.isSame(current, 'month'),
})
if (isEndOfWeek) {
res.push(days)
days = []
}
current = current.add(1, 'day')
}
return res
}
/**
* Generates an array of objects representing the header items for a calendar.
*
* @param {string|Dayjs} date - The date to generate the header items for.
* Can be a string in a supported
* format or a Dayjs instance.
* @returns {CalendarHeaderItem[]} An array of objects representing the header items for the calendar.
*/
const getHeaderItems = (date: string | Dayjs) => {
const res: CalendarHeaderItem[] = []
const currentDate = dayjs(date)
const startOfMonth = currentDate.startOf('month')
const startDate = startOfMonth.startOf('week')
let current = startDate
for (let i = 0; i < 7; i++) {
res.push({
weekday: current.format('ddd'),
isSaturday: current.day() === 6,
isSunday: current.day() === 0,
isThisMonth: current.isSame(currentDate, 'month'),
})
current = current.add(1, 'day')
}
return res
}
return {
getWeekItems,
getItems,
getHeaderItems,
}
}
Calendar UI with infinite scroll
CalendarListItem
CalendarListRow
CalendarMonthPicker
App.tsx
Calendar.tsx
CalendarContent.tsx
CalendarHeader.tsx
CalendarList.tsx
CalendarListHeader.tsx
createProvider.tsx
getCalendarMatrix.ts
isHTMLElement.ts
Provider.tsx
TodayButton.tsx
useCalendarId.ts
import { Flex, Stack } from '@chakra-ui/react'
import { Calendar } from './Calendar'
import { CalendarContent } from './CalendarContent'
import { CalendarHeader } from './CalendarHeader'
import { CalendarList } from './CalendarList'
import { CalendarListHeader } from './CalendarListHeader'
import { CalendarMonthPicker } from './CalendarMonthPicker'
import { TodayButton } from './TodayButton'
export function App() {
return (
<Flex flex={1} maxH={{ base: '400px', md: '764px' }}>
<Calendar>
<CalendarHeader>
<Flex flex={1}>
<CalendarMonthPicker />
</Flex>
<Flex ml="auto">
<Stack spacing={2} direction="row">
<TodayButton />
</Stack>
</Flex>
</CalendarHeader>
<CalendarListHeader />
<CalendarContent>
<CalendarList />
</CalendarContent>
</Calendar>
</Flex>
)
}
import type { FlexProps } from '@chakra-ui/react'
import { Flex } from '@chakra-ui/react'
import React from 'react'
import { Provider } from './Provider'
type Props = FlexProps
export function Calendar(props: Props) {
return (
<Provider>
<Component {...props} />
</Provider>
)
}
function Component(props: Props) {
return <Flex flex={1} h="full" flexDirection="column" {...props} />
}
import type { FlexProps } from '@chakra-ui/react'
import { Flex } from '@chakra-ui/react'
import type { PropsWithChildren } from 'react'
import React from 'react'
import { useCalendarContext } from './Provider'
type Props = FlexProps
const padding = 32
const calendarHeaderHeight = 40
const calendarListHeaderHeight = 24
const height = padding + calendarHeaderHeight + calendarListHeaderHeight
export function CalendarContent(props: PropsWithChildren<Props>) {
const { contentRef } = useCalendarContext()
return (
<Flex
ref={contentRef}
flex={1}
maxW="100%"
overflowY="scroll"
maxH={`calc(100vh - ${height}px)`}
minH={`calc(100vh - ${height}px)`}
position="relative"
h="full"
borderRightWidth={{ base: '0', md: '1px' }}
borderRightColor="gray.200"
_dark={{
borderRightColor: 'gray.700',
}}
{...props}
>
<Flex flex={1} flexDirection="column">
{props.children}
</Flex>
</Flex>
)
}
import type { FlexProps } from '@chakra-ui/react'
import { Flex } from '@chakra-ui/react'
import type { PropsWithChildren } from 'react'
import React from 'react'
type Props = FlexProps
export function CalendarHeader(props: PropsWithChildren<Props>) {
return (
<Flex
maxH="60px"
py={4}
h="40px"
alignItems="center"
_dark={{
borderBottomColor: 'gray.700',
}}
{...props}
/>
)
}
import { Flex } from '@chakra-ui/react'
import { formatISO } from 'date-fns/formatISO'
import { subDays } from 'date-fns/subDays'
import React, { useEffect } from 'react'
import { CalendarListItem } from './CalendarListItem'
import { CalendarListRow } from './CalendarListRow'
import { useCalendarContext } from './Provider'
import { useCalendarId } from './useCalendarId'
export function CalendarList() {
const { getCalendarListId, getCalendarListItemId } = useCalendarId()
const {
calendarRows,
onVisibleWhenScrollDown,
onVisibleWhenScrollUp,
isSecondRowOfMonth,
resetCount,
scrollToDate,
} = useCalendarContext()
useEffect(() => {
scrollToDate(subDays(new Date(), 7))
}, [scrollToDate])
return (
<Flex flex={1} flexDirection="column">
{calendarRows.map((r, i) => (
<CalendarListRow
observeScrollUp={i === 10}
observeScrollDown={i === calendarRows.length - 10}
onVisibleWhenScrollUp={onVisibleWhenScrollUp}
onVisibleWhenScrollDown={onVisibleWhenScrollDown}
isSecondRowOfMonth={isSecondRowOfMonth(r)}
key={`${getCalendarListId(r[0] as Date)}-${resetCount}`}
id={getCalendarListId(r[0] as Date)}
dateString={formatISO(r[0] as Date, { representation: 'date' })}
>
{r.map((date) => (
<CalendarListItem
key={getCalendarListItemId(date)}
id={getCalendarListItemId(date)}
dateString={formatISO(date, { representation: 'date' })}
/>
))}
</CalendarListRow>
))}
</Flex>
)
}
import type { FlexProps } from '@chakra-ui/react'
import { Flex } from '@chakra-ui/react'
import React from 'react'
type Props = FlexProps
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fir', 'Sat'] as const
export function CalendarListHeader(props: Props) {
return (
<Flex
flexShrink={0}
fontSize="xs"
color="gray.500"
fontWeight="medium"
h={6}
borderTopWidth={{ base: '0', md: '1px' }}
borderTopColor={{ base: 'gray.200' }}
borderBottomWidth={{ base: '0', md: '1px' }}
borderBottomColor={{ base: 'gray.200' }}
borderRightWidth={{ base: '0', md: '1px' }}
borderRightColor={{ base: 'gray.200' }}
borderLeftWidth={{ base: '0', md: '1px' }}
borderLeftColor={{ base: 'gray.200' }}
borderStyle="solid"
_dark={{
color: 'white',
borderTopColor: { base: 'gray.700' },
borderBottomColor: { base: 'gray.700' },
borderRightColor: { base: 'gray.700' },
borderLeftColor: { base: 'gray.700' },
}}
{...props}
>
{WEEKDAYS.map((w) => (
<Flex
key={w}
justifyContent={{ base: 'center', md: 'flex-start' }}
h="full"
pl={{ base: 0, md: 2 }}
w="full"
alignItems="center"
>
{w}
</Flex>
))}
</Flex>
)
}
import { addMonths } from 'date-fns/addMonths'
import { isLastDayOfMonth } from 'date-fns/isLastDayOfMonth'
import { subMonths } from 'date-fns/subMonths'
import type { MutableRefObject } from 'react'
import { useCallback, useMemo, useRef, useState } from 'react'
import { createProvider } from './createProvider'
import { getCalendarMatrix } from './getCalendarMatrix'
import { isHTMLElement } from './isHTMLElement'
import { useCalendarId } from './useCalendarId'
type ContextProps = {
calendarRows: Date[][]
onVisibleWhenScrollUp: (id: string) => void
onVisibleWhenScrollDown: (id: string) => void
isSecondRowOfMonth: (row: Date[]) => boolean
currentDate: Date
onNextMonth: () => void
onPrevMonth: () => void
resetMonth: () => void
resetCount: number
setMonth: (date: Date) => void
scrollToDate: (date: Date) => void
contentRef: MutableRefObject<HTMLElement | null>
}
const useValue = (): ContextProps => {
const contentRef = useRef<HTMLElement | null>(null)
const [currentDate, setCurrentDate] = useState(new Date())
const [baseDate, setBaseDate] = useState(new Date())
const { getCalendarListId, getCalendarListItemId } = useCalendarId()
const [resetCount, setResetCount] = useState(0)
const incrementResetCount = useCallback(() => {
setResetCount((s) => s + 1)
}, [])
const onNextMonth = useCallback(() => {
setCurrentDate((s) => addMonths(s, 1))
}, [])
const onPrevMonth = useCallback(() => {
setCurrentDate((s) => subMonths(s, 1))
}, [])
const setMonth = useCallback((date: Date) => {
setCurrentDate(date)
setBaseDate(date)
}, [])
const resetMonth = useCallback(() => {
setMonth(new Date())
setBaseDate(new Date())
incrementResetCount()
}, [setMonth, incrementResetCount])
const calendarRows = useMemo<Date[][]>(
() => getCalendarMatrix(subMonths(baseDate, 6), addMonths(baseDate, 6)),
[baseDate],
)
const isSecondRowOfMonth = useCallback(
(row: Date[]) => {
return !!(
calendarRows
.filter((c) => c.some((date) => isLastDayOfMonth(date)))
.find(
(c) =>
getCalendarListId(c[0] as Date) ===
getCalendarListId(row[0] as Date),
) ?? false
)
},
[calendarRows, getCalendarListId],
)
const onVisibleWhenScrollUp = useCallback(() => {
setBaseDate((s) => subMonths(s, 3))
}, [])
const onVisibleWhenScrollDown = useCallback(() => {
setBaseDate((s) => addMonths(s, 3))
}, [])
const scrollToDate = useCallback(
(date: Date) => {
setTimeout(() => {
const element = document.getElementById(getCalendarListItemId(date))
if (!isHTMLElement(element)) return
const offsetTop = element.offsetTop
contentRef.current?.scrollTo(0, offsetTop)
})
},
[getCalendarListItemId],
)
return {
calendarRows,
onVisibleWhenScrollUp,
onVisibleWhenScrollDown,
isSecondRowOfMonth,
currentDate,
onNextMonth,
onPrevMonth,
resetMonth,
resetCount,
setMonth,
scrollToDate,
contentRef,
}
}
useValue.__PROVIDER__ = 'CalendarInfiniteScroll'
export const { Provider, useContext: useCalendarContext } =
createProvider(useValue)
import { Button } from '@chakra-ui/react'
import React, { useCallback } from 'react'
import { useCalendarContext } from './Provider'
export function TodayButton() {
const { resetMonth, scrollToDate } = useCalendarContext()
const handleClickToday = useCallback(() => {
resetMonth()
scrollToDate(new Date())
}, [resetMonth, scrollToDate])
return (
<Button variant="ghost" size="xs" onClick={handleClickToday}>
Today
</Button>
)
}
import { forwardRef } from '@chakra-ui/react'
import type { PropsWithChildren } from 'react'
import React, { createContext, memo } from 'react'
/**
* Creates a provider component for a given context.
*
* @param {function} useValue - A function that takes props and returns the context value.
*
* @return An object containing the provider component, the context object, and a hook to consume the context.
*/
export function createProvider<
ContextProps extends object,
Props extends object,
>(useValue: (props: Props) => ContextProps) {
const Context = createContext<ContextProps>({} as ContextProps)
const useContext = () => {
const context = React.useContext(Context)
if (!Object.keys(context).length) {
throw new Error(
`【${
(useValue as any).__PROVIDER__
}】Context needs to be consumed in Provider`,
)
}
return context
}
const Provider: React.FC<PropsWithChildren<Props>> = memo<
PropsWithChildren<Props>
>(
forwardRef((props, ref) => (
<Component {...props} ref={ref} {...useValue(props)} />
)) as React.FC<PropsWithChildren<Props>>,
)
Provider.displayName = 'Provider'
const Component: React.FC<PropsWithChildren<Props> & ContextProps> = memo<
PropsWithChildren<Props> & ContextProps
>(
forwardRef(({ children, ...rest }, ref) => {
return (
<Context.Provider value={{ ...(rest as unknown as ContextProps), ref }}>
{children}
</Context.Provider>
)
}) as React.FC<PropsWithChildren<Props> & ContextProps>,
)
return {
Provider,
Context,
useContext,
}
}
import { eachDayOfInterval } from 'date-fns/eachDayOfInterval'
import { eachWeekOfInterval } from 'date-fns/eachWeekOfInterval'
import { endOfISOWeek } from 'date-fns/endOfISOWeek'
import { endOfMonth } from 'date-fns/endOfMonth'
import { startOfISOWeek } from 'date-fns/startOfISOWeek'
import { startOfMonth } from 'date-fns/startOfMonth'
export const getCalendarMatrix = (start: Date, end: Date) => {
const matrix = eachWeekOfInterval(
{
start: startOfMonth(start),
end: endOfMonth(end),
},
{ weekStartsOn: 1 },
)
return matrix.map((weekDay) =>
eachDayOfInterval({
start: startOfISOWeek(weekDay),
end: endOfISOWeek(weekDay),
}),
)
}
export const isHTMLElement = (obj: any): obj is HTMLElement =>
obj instanceof Element
import { formatISO } from 'date-fns/formatISO'
import { useCallback } from 'react'
export const useCalendarId = () => {
const getCalendarListId = useCallback((date: Date) => {
return `calendar-list-${formatISO(date, {
representation: 'date',
})}`
}, [])
const getCalendarListItemId = useCallback((date: Date) => {
return `calendar-list-item-${formatISO(date, {
representation: 'date',
})}`
}, [])
return {
getCalendarListId,
getCalendarListItemId,
}
}
import type { FlexProps } from '@chakra-ui/react'
import { Divider } from '@chakra-ui/react'
import { Flex, Text } from '@chakra-ui/react'
import React from 'react'
import { useListItemStyle } from './hooks/useListItemStyle'
type Props = {
dateString: string
} & FlexProps
export function CalendarListItem(props: Props) {
const { dateString, ...rest } = props
const { dateText, borderStyle, textStyle } = useListItemStyle({
dateString,
})
return (
<Flex
bg="white"
flexDirection="column"
alignItems={{ base: 'center', md: 'flex-start' }}
minH={{ base: '70px', md: '170px' }}
w="full"
maxW="full"
minW={0}
p={2}
borderLeftWidth={{ base: '0', md: '1px' }}
borderLeftColor="gray.200"
borderBottomWidth={{ base: '0', md: '1px' }}
borderBottomColor="gray.200"
position="relative"
_dark={{
borderLeftColor: 'gray.700',
borderBottomColor: 'gray.700',
bg: '#0c0a09',
}}
{...rest}
>
<Flex>
<Divider
position="absolute"
left="-1px"
top="0"
w="calc(100% + 1px)"
borderTop="3px"
borderStyle="solid"
borderTopColor="transparent"
borderBottomWidth={0}
{...borderStyle}
/>
<Text
fontSize="xs"
fontWeight="medium"
color="gray.500"
_dark={{
color: 'white',
}}
{...textStyle}
>
{dateText}
</Text>
</Flex>
</Flex>
)
}
import type { FlexProps } from '@chakra-ui/react'
import { Flex } from '@chakra-ui/react'
import React from 'react'
import { MonthObserver } from './MonthObserver'
import { ScrollDownObserver } from './ScrollDownObserver'
import { ScrollUpObserver } from './ScrollUpObserver'
type Props = {
observeScrollUp?: boolean
observeScrollDown?: boolean
onVisibleWhenScrollUp: (id: string) => void
onVisibleWhenScrollDown: (id: string) => void
isSecondRowOfMonth: boolean
dateString: string
} & FlexProps
export function CalendarListRow(props: Props) {
const {
observeScrollUp,
observeScrollDown,
onVisibleWhenScrollUp,
onVisibleWhenScrollDown,
isSecondRowOfMonth,
dateString,
...rest
} = props
return (
<MonthObserver isSecondRowOfMonth={isSecondRowOfMonth} id={props.id}>
<ScrollUpObserver
observeScrollUp={observeScrollUp}
onVisible={onVisibleWhenScrollUp}
dateString={dateString}
>
<ScrollDownObserver
observeScrollDown={observeScrollDown}
onVisible={onVisibleWhenScrollDown}
dateString={dateString}
>
<Flex {...rest} flex={1} />
</ScrollDownObserver>
</ScrollUpObserver>
</MonthObserver>
)
}
import type { FlexProps } from '@chakra-ui/react'
import { Flex } from '@chakra-ui/react'
import React, { useEffect, useRef } from 'react'
import { useInView } from 'react-intersection-observer'
import { useCalendarContext } from '../Provider'
type Props = {
isSecondRowOfMonth: boolean
} & FlexProps
export function MonthObserver(props: Props) {
const { isSecondRowOfMonth, id, ...rest } = props
const { ref, entry } = useInView({
skip: !isSecondRowOfMonth,
})
const isFirst = useRef(true)
const { onNextMonth, onPrevMonth } = useCalendarContext()
useEffect(() => {
if (!isSecondRowOfMonth) return
// When scrolling down and the calendar changes to the next month
if (
!isFirst.current &&
!entry?.isIntersecting &&
entry?.intersectionRatio === 0 &&
entry.boundingClientRect.top < 0 && // top is less than 0 when only scrolling
entry.boundingClientRect.bottom > 0 // bottom is more than 0 when only scrolling
) {
onNextMonth()
}
// When scrolling up and the calendar changes to the previous month
if (
!isFirst.current &&
entry?.isIntersecting &&
entry?.intersectionRatio > 0 &&
entry.boundingClientRect.top < 0
) {
onPrevMonth()
}
if (entry && isFirst.current) {
isFirst.current = false
}
}, [entry, isSecondRowOfMonth, id, onNextMonth, onPrevMonth])
return <Flex {...rest} ref={ref} flex={1} />
}
import type { FlexProps } from '@chakra-ui/react'
import { Flex } from '@chakra-ui/react'
import React, { useEffect, useRef } from 'react'
import { useInView } from 'react-intersection-observer'
type Props = {
observeScrollDown?: boolean
onVisible: (id: string) => void
dateString: string
} & FlexProps
export function ScrollDownObserver(props: Props) {
const { observeScrollDown, onVisible, dateString, ...rest } = props
const { ref, inView } = useInView({
skip: !observeScrollDown,
triggerOnce: true,
})
const hasScrolledDown = useRef(false)
useEffect(() => {
if (!inView) return
if (hasScrolledDown.current) return
if (observeScrollDown) {
onVisible(dateString)
hasScrolledDown.current = true
}
}, [inView, observeScrollDown, onVisible, dateString])
return <Flex {...rest} ref={ref} flex={1} />
}
import type { FlexProps } from '@chakra-ui/react'
import { Flex } from '@chakra-ui/react'
import React, { useEffect, useRef } from 'react'
import { useInView } from 'react-intersection-observer'
type Props = {
observeScrollUp?: boolean
onVisible: (id: string) => void
dateString: string
} & FlexProps
export function ScrollUpObserver(props: Props) {
const { observeScrollUp, onVisible, dateString, ...rest } = props
const { ref, inView } = useInView({
skip: !observeScrollUp,
triggerOnce: true,
})
const hasScrolledUp = useRef(false)
useEffect(() => {
if (!inView) return
if (hasScrolledUp.current) return
if (observeScrollUp) {
onVisible(dateString)
hasScrolledUp.current = true
}
}, [inView, observeScrollUp, onVisible, dateString])
return <Flex {...rest} ref={ref} flex={1} />
}
import { ChevronDownIcon } from '@chakra-ui/icons'
import type { FlexProps } from '@chakra-ui/react'
import {
IconButton,
Flex,
Text,
PortalManager,
Link,
Popover,
PopoverTrigger,
useDisclosure,
} from '@chakra-ui/react'
import { format } from 'date-fns/format'
import React, { useMemo } from 'react'
import { useCalendarContext } from '../Provider'
import { Content } from './Content'
type Props = FlexProps
export function CalendarMonthPicker(props: Props) {
const { currentDate } = useCalendarContext()
const dateText = useMemo(() => {
return format(currentDate, 'MMMM y')
}, [currentDate])
const popoverDisclosure = useDisclosure()
return (
<Flex {...props} alignItems="center">
<Text fontWeight="medium">{dateText}</Text>
<PortalManager zIndex={1500}>
<Popover
isOpen={popoverDisclosure.isOpen}
isLazy
closeOnBlur={false}
placement="bottom-start"
>
<PopoverTrigger>
<Link onClick={popoverDisclosure.onOpen}>
<IconButton
ml={1}
h={6}
aria-label="Pick month"
icon={<ChevronDownIcon color="gray.500" />}
variant="ghost"
size="sm"
/>
</Link>
</PopoverTrigger>
{popoverDisclosure.isOpen && (
<Content onClose={popoverDisclosure.onClose} />
)}
</Popover>
</PortalManager>
</Flex>
)
}
import { ChevronRightIcon, ChevronLeftIcon } from '@chakra-ui/icons'
import type { FlexProps, PopoverProps } from '@chakra-ui/react'
import {
Flex,
Portal,
Text,
PopoverContent,
PopoverHeader,
useOutsideClick,
} from '@chakra-ui/react'
import { addYears } from 'date-fns/addYears'
import { eachMonthOfInterval } from 'date-fns/eachMonthOfInterval'
import { endOfYear } from 'date-fns/endOfYear'
import { format } from 'date-fns/format'
import { formatISO } from 'date-fns/formatISO'
import { isSameMonth } from 'date-fns/isSameMonth'
import { startOfYear } from 'date-fns/startOfYear'
import { subYears } from 'date-fns/subYears'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCalendarContext } from '../Provider'
type Props = {
onClose: () => void
} & PopoverProps
export function Content(props: Props) {
const { onClose } = props
const ref = useRef<HTMLElement | null>(null)
useOutsideClick({
ref,
handler: onClose,
})
const { currentDate, setMonth, scrollToDate } = useCalendarContext()
const [date, setDate] = useState<Date>(currentDate)
const handleNextYear = useCallback(() => {
setDate((s) => addYears(s, 1))
}, [])
const handlePrevYear = useCallback(() => {
setDate((s) => subYears(s, 1))
}, [])
const months = useMemo<Date[]>(() => {
const start = startOfYear(date)
const end = endOfYear(date)
return eachMonthOfInterval({ start, end })
}, [date])
const currentMonthStyle = useCallback(
(val: Date): FlexProps => {
if (isSameMonth(date, val))
return {
_after: {
bg: 'primary',
bottom: 1,
content: '""',
height: '2px',
left: 3,
position: 'absolute',
right: 3,
color: 'primary',
},
fontWeight: 'bold',
}
return {}
},
[date],
)
const handleClickMonth = useCallback(
(date: Date) => {
setMonth(date)
onClose()
scrollToDate(date)
},
[setMonth, onClose, scrollToDate],
)
useEffect(() => {
setDate(currentDate)
}, [currentDate])
return (
<Portal>
<PopoverContent w="210px" maxW="210px" h="145px" ref={ref}>
<PopoverHeader>
<Flex>
<ChevronLeftIcon
color="gray.500"
onClick={handlePrevYear}
cursor="pointer"
/>
<Text flex={1} fontSize="sm" textAlign="center">
{format(date, 'y')}
</Text>
<ChevronRightIcon
color="gray.500"
onClick={handleNextYear}
cursor="pointer"
/>
</Flex>
</PopoverHeader>
<Flex flexWrap="wrap" flex={1}>
{months.map((d) => (
<Flex
key={formatISO(d, { representation: 'date' })}
fontSize="sm"
color="gray.500"
textTransform="uppercase"
w="25%"
alignItems="center"
justifyContent="center"
position="relative"
onClick={() => handleClickMonth(d)}
_hover={{ color: 'teal.300' }}
transition=".15s ease-out"
cursor="pointer"
{...currentMonthStyle(d)}
>
{format(d, 'MMM')}
</Flex>
))}
</Flex>
</PopoverContent>
</Portal>
)
}
import type { FlexProps, TextProps } from '@chakra-ui/react'
import { format } from 'date-fns/format'
import { isFirstDayOfMonth } from 'date-fns/isFirstDayOfMonth'
import { isToday } from 'date-fns/isToday'
import { useMemo } from 'react'
type Props = {
dateString: string
}
export const useListItemStyle = (props: Props) => {
const { dateString } = props
const date = useMemo(() => new Date(dateString), [dateString])
const borderStyle = useMemo<FlexProps>(() => {
if (isToday(date))
return {
borderTopColor: 'cyan.400',
}
if (isFirstDayOfMonth(date))
return {
borderTopColor: 'gray.500',
_dark: { borderTopColor: 'gray.200' },
}
return {}
}, [date])
const textStyle = useMemo<TextProps>(() => {
if (isToday(date)) return { color: 'cyan.400', fontWeight: 'bold' }
return {}
}, [date])
const dateText = useMemo(() => {
if (isFirstDayOfMonth(date)) return format(date, 'MMM d')
return format(date, 'd')
}, [date])
return {
borderStyle,
textStyle,
dateText,
}
}