diff --git a/package-lock.json b/package-lock.json index 7dc2be0..a6dd170 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "nardo-primitives", "version": "0.1.0", "license": "MIT", + "dependencies": { + "date-fns": "^2.29.3" + }, "devDependencies": { "@babel/preset-react": "^7.18.6", "@rollup/plugin-babel": "^6.0.3", @@ -7558,6 +7561,18 @@ "integrity": "sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw==", "dev": true }, + "node_modules/date-fns": { + "version": "2.29.3", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.29.3.tgz", + "integrity": "sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA==", + "engines": { + "node": ">=0.11" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/date-fns" + } + }, "node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", @@ -21196,6 +21211,11 @@ "integrity": "sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw==", "dev": true }, + "date-fns": { + "version": "2.29.3", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.29.3.tgz", + "integrity": "sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA==" + }, "debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", diff --git a/package.json b/package.json index e53435f..c9f1a05 100644 --- a/package.json +++ b/package.json @@ -58,5 +58,8 @@ "peerDependencies": { "react": "^18.2.0", "react-dom": "^18.2.0" + }, + "dependencies": { + "date-fns": "^2.29.3" } } diff --git a/src/components/Calendar/Calendar.children.tsx b/src/components/Calendar/Calendar.children.tsx new file mode 100644 index 0000000..cae00d3 --- /dev/null +++ b/src/components/Calendar/Calendar.children.tsx @@ -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 ( + + ) +} + +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( + () => + 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 ( +
+ {datesGrid.map((datesGridItem) => render(datesGridItem))} +
+ ) +} diff --git a/src/components/Calendar/Calendar.stories.styles.css b/src/components/Calendar/Calendar.stories.styles.css new file mode 100644 index 0000000..30774ff --- /dev/null +++ b/src/components/Calendar/Calendar.stories.styles.css @@ -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); +} diff --git a/src/components/Calendar/Calendar.stories.tsx b/src/components/Calendar/Calendar.stories.tsx new file mode 100644 index 0000000..8e360b7 --- /dev/null +++ b/src/components/Calendar/Calendar.stories.tsx @@ -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 = { + /* 👇 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 + +/* + *👇 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 ( + +
+ + Previous + + {date.toDateString()}} + /> + + Next + +
+ ( + + )} + /> +
+ ) +} + +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 ( + <> + setIsCalendarVisible(true)} + /> + {isCalendarVisible && ( + +
+ + Previous + + {date.toDateString()}} + /> + + Next + +
+ ( + + )} + /> +
+ )} + + ) +} diff --git a/src/components/Calendar/Calendar.tsx b/src/components/Calendar/Calendar.tsx new file mode 100644 index 0000000..c94d6cc --- /dev/null +++ b/src/components/Calendar/Calendar.tsx @@ -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({}) + +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() + 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 ( + +
setCalendarRef(node)} + {...restProps} + onKeyDown={onKeyDown} + > + {children} +
+
+ ) +} + +const CalendarNamespace = Object.assign(Calendar, { + DaysGrid, + MonthSwitcherButton, + SelectedDate, +}) + +export default CalendarNamespace diff --git a/src/components/Calendar/Calendar.types.ts b/src/components/Calendar/Calendar.types.ts new file mode 100644 index 0000000..6d9e565 --- /dev/null +++ b/src/components/Calendar/Calendar.types.ts @@ -0,0 +1,36 @@ +export interface CalendarProps extends React.PropsWithChildren { + defaultDate?: Date + value?: Date + onChange?: React.Dispatch> + className?: string +} + +export type CalendarContextType = { + currentDate?: Date + setCurrentDate?: React.Dispatch> + displayedDate?: Date + setDisplayedDate?: React.Dispatch> +} + +export type MonthDirectionType = 'next' | 'previous' + +export type MonthSwitcherProps = React.HTMLAttributes & + React.PropsWithChildren & { + direction: MonthDirectionType + onClick?: React.MouseEventHandler + } + +export type DatesGridItemType = { + date: Date + onClick: () => void +} + +export type DaysGridProps = React.HTMLAttributes & { + completeWithExtraDays?: MonthDirectionType | 'both' + render: (args: DatesGridItemType) => React.ReactNode +} + +export type SelectedDateProps = React.HTMLAttributes & { + scope: 'currentDate' | 'displayedDate' + render: (scopedDateObject: Date) => JSX.Element +} diff --git a/src/components/index.ts b/src/components/index.ts index e69de29..393f24f 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -0,0 +1 @@ +export { default } from '@/components/Calendar/Calendar'