Skip to content
Open
20 changes: 20 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,5 +58,8 @@
"peerDependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"dependencies": {
"date-fns": "^2.29.3"
}
}
104 changes: 104 additions & 0 deletions src/components/Calendar/Calendar.children.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import React, { useContext, useMemo } from 'react'
import { CalendarContext } from './Calendar'
import {
DatesGridItemType,
DaysGridProps,
MonthSwitcherProps,
SelectedDateProps,
} from './Calendar.types'
import {
endOfWeek,
eachDayOfInterval,
startOfWeek,
lastDayOfMonth,
isSameMonth,
addMonths,
differenceInDays,
getDaysInMonth,
setDate,
startOfMonth,
endOfMonth,
} from 'date-fns'

export const MonthSwitcherButton = (props: MonthSwitcherProps) => {
const { direction, children, onClick, ...restProps } = props
const { setDisplayedDate } = useContext(CalendarContext)

const handleOnClick = (event) => {
onClick && onClick(event)
setDisplayedDate((prevState) =>
addMonths(prevState, direction === 'next' ? 1 : -1)
)
}

return (
<button
onClick={handleOnClick}
{...restProps}
>
{children}
</button>
)
}

export const SelectedDate = (props: SelectedDateProps) => {
const { scope, render } = props
const { currentDate, displayedDate } = useContext(CalendarContext)

if (
(scope === 'currentDate' && !currentDate) ||
(scope === 'displayedDate' && !displayedDate)
) {
throw new Error(
`Requested selected date is out of scope: ${scope} value is not provided or avaliable.`
)
}

return render(scope === 'currentDate' ? currentDate : displayedDate)
}

export const DaysGrid = (props: DaysGridProps) => {
const { render, completeWithExtraDays, ...restProps } = props
const { setCurrentDate, displayedDate, setDisplayedDate } =
useContext(CalendarContext)

const datesGrid = useMemo<DatesGridItemType[]>(
() =>
eachDayOfInterval({
start: startOfWeek(startOfMonth(displayedDate), { weekStartsOn: 1 }),
end: endOfWeek(endOfMonth(displayedDate), { weekStartsOn: 1 }),
}).map((date) => ({
date,
onClick: () => {
;(setCurrentDate ?? setDisplayedDate)(date)
!isSameMonth(date, displayedDate) && setDisplayedDate(date)
},
})),
[displayedDate]
)

const containerDataAttributes = useMemo(
() => ({
'data-calendar-previous-month-days': differenceInDays(
setDate(displayedDate, 1),
datesGrid.at(0).date
),
'data-calendar-next-month-days': differenceInDays(
datesGrid.at(-1).date,
lastDayOfMonth(displayedDate)
),
'data-calendar-days': getDaysInMonth(displayedDate),
'data-days-grid': '',
}),
[datesGrid]
)

return (
<div
{...containerDataAttributes}
{...restProps}
>
{datesGrid.map((datesGridItem) => render(datesGridItem))}
</div>
)
}
15 changes: 15 additions & 0 deletions src/components/Calendar/Calendar.stories.styles.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
.calendar__header {
display: flex;
justify-content: space-between;
}

.calendar {
display: flex;
flex-flow: column;
max-width: 320px;
}

.calendar__body {
display: grid;
grid-template-columns: repeat(7, 1fr);
}
109 changes: 109 additions & 0 deletions src/components/Calendar/Calendar.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import React, { useMemo, useState } from 'react'
import type { Meta, StoryFn, StoryObj } from '@storybook/react'
import './Calendar.stories.styles.css'

import Calendar from './Calendar'

const meta: Meta<typeof Calendar> = {
/* 👇 The title prop is optional.
* See https://storybook.js.org/docs/7.0/react/configure/overview#configure-story-loading
* to learn how to generate automatic titles
*/
title: 'Calendar',
component: Calendar,
}

export default meta
type Story = StoryObj<typeof Calendar>

/*
*👇 Render functions are a framework specific feature to allow you control on how the component renders.
* See https://storybook.js.org/docs/7.0/react/api/csf
* to learn how to use render functions.
*/
export const Default: StoryFn = () => {
return (
<Calendar className="calendar">
<div className="calendar__header">
<Calendar.MonthSwitcherButton direction="previous">
Previous
</Calendar.MonthSwitcherButton>
<Calendar.SelectedDate
scope="displayedDate"
render={(date) => <span>{date.toDateString()}</span>}
/>
<Calendar.MonthSwitcherButton direction="next">
Next
</Calendar.MonthSwitcherButton>
</div>
<Calendar.DaysGrid
className="calendar__body"
completeWithExtraDays="both"
render={({ date, onClick }) => (
<button
key={date.toISOString()}
onClick={onClick}
>
{date.getDate()}
</button>
)}
/>
</Calendar>
)
}

export const AsDatePicker: StoryFn = () => {
const [date, setDate] = useState(new Date())
const [isCalendarVisible, setIsCalendarVisible] = useState(false)

const dateString = useMemo(() => {
const day = date.getDate().toString().padStart(2, '0')
const month = (date.getMonth() + 1).toString().padStart(2, '0')
const year = date.getFullYear().toString()

return `${year}-${month}-${day}`
}, [date])

return (
<>
<input
type="text"
value={dateString}
readOnly
onFocus={() => setIsCalendarVisible(true)}
/>
{isCalendarVisible && (
<Calendar
value={date}
onChange={setDate}
className="calendar"
>
<div className="calendar__header">
<Calendar.MonthSwitcherButton direction="previous">
Previous
</Calendar.MonthSwitcherButton>
<Calendar.SelectedDate
scope="displayedDate"
render={(date) => <span>{date.toDateString()}</span>}
/>
<Calendar.MonthSwitcherButton direction="next">
Next
</Calendar.MonthSwitcherButton>
</div>
<Calendar.DaysGrid
className="calendar__body"
completeWithExtraDays="both"
render={({ date, onClick }) => (
<button
key={date.toISOString()}
onClick={onClick}
>
{date.getDate()}
</button>
)}
/>
</Calendar>
)}
</>
)
}
117 changes: 117 additions & 0 deletions src/components/Calendar/Calendar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// @ts-nocheck
import { addMonths, isAfter } from 'date-fns'
import React, { createContext, useState, useRef, useCallback } from 'react'
import {
DaysGrid,
MonthSwitcherButton,
SelectedDate,
} from './Calendar.children'
import { CalendarContextType, CalendarProps } from './Calendar.types'

export const CalendarContext = createContext<CalendarContextType>({})

const Calendar = (props: CalendarProps) => {
const {
children,
defaultDate = new Date(),
value: currentDate,
onChange: setCurrentDate,
...restProps
} = props

if ((currentDate && !setCurrentDate) || (!currentDate && setCurrentDate)) {
throw new Error('xd')
}

const [displayedDate, setDisplayedDate] = useState(
new Date(defaultDate.getFullYear(), defaultDate.getMonth(), 1)
)
const focusableElementPosition = useRef(null)

const getElementWithinOffset = (targetElement, focusOffset) => {
const elementsRoot = calendarRef.current.querySelector('[data-days-grid]')
const elements = [...elementsRoot.children]

return elements[elements.indexOf(targetElement) + focusOffset]
}

const getBoundaryElement = (bound: 'first' | 'last') => {
const elementsRoot = calendarRef.current.querySelector('[data-days-grid]')
return elementsRoot[`${bound}Child`]
}

const calendarRef = useRef<HTMLDivElement>()
const setCalendarRef = useCallback((node) => {
if (!node) {
return
}

calendarRef.current = node

if (focusableElementPosition.current) {
const focusableElement = getBoundaryElement(
focusableElementPosition.current
)
focusableElement.focus()
}
}, [])

const onKeyDown: React.KeyboardEventHandler = (event) => {
const { key, target } = event

const offsetsMap = {
ArrowUp: -7,
ArrowDown: 7,
ArrowLeft: -1,
ArrowRight: 1,
}

// temporary solution to skip handler for buttons other than arrows
if (!Object.keys(offsetsMap).includes(key)) {
return
}

let focusableElement = getElementWithinOffset(target, offsetsMap[key])

if (!focusableElement) {
const shouldIncrementMonth = offsetsMap[key] < 0

focusableElementPosition.current = shouldIncrementMonth ? 'last' : 'first'
setDisplayedDate((prevState) =>
addMonths(prevState, shouldIncrementMonth ? -1 : 1)
)
return
}

if (focusableElement.focus) {
focusableElement.focus()
}
}

return (
<CalendarContext.Provider
value={{
currentDate,
setCurrentDate,
displayedDate,
setDisplayedDate,
}}
>
<div
ref={(node) => setCalendarRef(node)}
{...restProps}
onKeyDown={onKeyDown}
>
{children}
</div>
</CalendarContext.Provider>
)
}

const CalendarNamespace = Object.assign(Calendar, {
DaysGrid,
MonthSwitcherButton,
SelectedDate,
})

export default CalendarNamespace
Loading