From 0ca0801d0e48b895acbad9a67b4bacd3f859e3b3 Mon Sep 17 00:00:00 2001 From: Stephanie Yang Date: Thu, 15 Feb 2024 00:06:19 -0600 Subject: [PATCH] add calendar range to handle multi view calendars --- src/form/Calendar/Calendar.module.css | 15 +- src/form/Calendar/Calendar.story.tsx | 25 +- src/form/Calendar/Calendar.tsx | 5 + .../CalendarDays/CalendarDays.module.css | 30 +- .../Calendar/CalendarDays/CalendarDays.tsx | 113 +++++++- src/form/Calendar/CalendarRange.tsx | 273 ++++++++++++++++++ src/form/Calendar/index.ts | 1 + 7 files changed, 445 insertions(+), 17 deletions(-) create mode 100644 src/form/Calendar/CalendarRange.tsx diff --git a/src/form/Calendar/Calendar.module.css b/src/form/Calendar/Calendar.module.css index 3a8fbac42..c0e0f3575 100644 --- a/src/form/Calendar/Calendar.module.css +++ b/src/form/Calendar/Calendar.module.css @@ -1,9 +1,22 @@ .container { + overflow: hidden; + .header { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--spacing-sm); } - overflow: hidden; + + .multiviewLabel { + display: flex; + flex-grow: 1; + justify-content: space-around; + gap: var(--spacing-lg); + } + + .calendars { + display: flex; + gap: var(--spacing-lg); + } } diff --git a/src/form/Calendar/Calendar.story.tsx b/src/form/Calendar/Calendar.story.tsx index 9d5716ae1..de3103f36 100644 --- a/src/form/Calendar/Calendar.story.tsx +++ b/src/form/Calendar/Calendar.story.tsx @@ -1,6 +1,7 @@ -import React, { useState } from 'react'; +import { useState } from 'react'; import { Card } from '../../layout/Card'; import { Calendar } from './Calendar'; +import { CalendarRange } from './CalendarRange'; import { add, addMonths, sub } from 'date-fns'; import { Divider, Stack } from '../../layout'; @@ -112,3 +113,25 @@ export const Range = () => { ); }; + +export const Multiview = () => { + const [range, setRange] = useState<[Date, Date]>(); + + return ( + + setRange(val as [Date, Date])} + numMonths={3} + enableDayOfWeek + isRange + /> + + + {range + ? `${range[0]?.toLocaleDateString()}-${range[1]?.toLocaleDateString()}` + : 'No date selected'} + + + ); +}; diff --git a/src/form/Calendar/Calendar.tsx b/src/form/Calendar/Calendar.tsx index a110b5e90..2c87ee5af 100644 --- a/src/form/Calendar/Calendar.tsx +++ b/src/form/Calendar/Calendar.tsx @@ -68,6 +68,11 @@ export interface CalendarProps { */ dateFormat?: string; + /** + * Whether to display day of week labels + */ + enableDayOfWeek?: boolean; + /** * Whether to animate the calendar. */ diff --git a/src/form/Calendar/CalendarDays/CalendarDays.module.css b/src/form/Calendar/CalendarDays/CalendarDays.module.css index 04a5ad773..d28503288 100644 --- a/src/form/Calendar/CalendarDays/CalendarDays.module.css +++ b/src/form/Calendar/CalendarDays/CalendarDays.module.css @@ -1,12 +1,14 @@ .week { - display: flex; + display: grid; + grid-template-columns: repeat(7, 1fr); .day { flex: 1; transition: border 100ms ease-in-out; padding: var(--calendar-spacing); + text-align: center; - &:not(.selected).outside { + &:not(.range).outside { opacity: 0.6; } @@ -17,12 +19,34 @@ &.startRangeDate { border-top-left-radius: var(--button-border-radius); + } + + &.roundStartDateBottom { border-bottom-left-radius: var(--button-border-radius); } &.endRangeDate { - border-top-right-radius: var(--button-border-radius); border-bottom-right-radius: var(--button-border-radius); } + + &.roundEndDateTop { + border-top-right-radius: var(--button-border-radius); + } + + &.hideDay { + visibility: hidden !important; + } + } +} + +.weekLabels { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: var(--spacing-sm); + width: 100%; + margin: var(--spacing-md) 0 var(--spacing-sm); + + .dayOfWeek { + text-align: center; } } diff --git a/src/form/Calendar/CalendarDays/CalendarDays.tsx b/src/form/Calendar/CalendarDays/CalendarDays.tsx index 351b022ea..4d90efcd8 100644 --- a/src/form/Calendar/CalendarDays/CalendarDays.tsx +++ b/src/form/Calendar/CalendarDays/CalendarDays.tsx @@ -1,4 +1,4 @@ -import React, { FC, useCallback, useMemo, useState } from 'react'; +import { FC, useCallback, useMemo, useState } from 'react'; import classNames from 'classnames'; import { addDays, @@ -7,7 +7,8 @@ import { isSameDay, set, max as maxDate, - min as minDate + min as minDate, + isSameMonth } from 'date-fns'; import { Button } from '../../../elements/Button'; import { getWeeks } from '../utils'; @@ -26,6 +27,11 @@ export interface CalendarDaysProps { */ current?: Date | [Date, Date]; + /** + * The currently hovered date. + */ + hover?: Date | null; + /** * The minimum selectable date for the calendar, as a Date object. */ @@ -41,6 +47,21 @@ export interface CalendarDaysProps { */ disabled?: boolean; + /** + * Whether to display days of previous month + */ + hidePrevMonthDays?: boolean; + + /** + * Whether to display days of next month + */ + hideNextMonthDays?: boolean; + + /** + * Whether to display day of week labels + */ + enableDayOfWeek?: boolean; + /** * Whether the calendar is a range picker. */ @@ -65,6 +86,11 @@ export interface CalendarDaysProps { * A callback function that is called when a day is selected. */ onChange: (date: Date) => void; + + /** + * A callback function that is called when a day is hovered. + */ + onHover?: (date: Date | null) => void; } const ZERO_TIME = { @@ -74,23 +100,35 @@ const ZERO_TIME = { milliseconds: 0 }; +const DAY_OF_WEEK_LABELS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']; + export const CalendarDays: FC = ({ value = new Date(), current = new Date(), + hover = null, isRange, disabled, min: minLimit, max, animated, xAnimation = 0, - onChange + enableDayOfWeek, + hidePrevMonthDays, + hideNextMonthDays, + onChange, + onHover }) => { - const [hoveringDate, setHoveringDate] = useState(null); + const [hoveringDate, setHoveringDate] = useState(hover); const weeks = useMemo(() => getWeeks(value), [value]); const maxLimit = useMemo(() => (max === 'now' ? new Date() : max), [max]); const renderDay = useCallback( (day, ii) => { + // Determine if the day should be shown or not + const hideDay = + (day.isPreviousMonth && hidePrevMonthDays) || + (day.isNextMonth && hideNextMonthDays); + // Determine if the day is disabled const isDisabled = disabled || @@ -98,12 +136,13 @@ export const CalendarDays: FC = ({ (maxLimit && isAfter(day.date, maxLimit)); // Determine that date is in selected range + const currentHover = hover || hoveringDate; const isSelectionStarted = Array.isArray(current) && isSameDay(...current); const prevDayRangeStart = set( addDays( - hoveringDate && isSelectionStarted - ? minDate([current?.[0], hoveringDate]) + currentHover && isSelectionStarted + ? minDate([current?.[0], currentHover]) : current?.[0], -1 ), @@ -111,13 +150,14 @@ export const CalendarDays: FC = ({ ); const nextDayRangeEnd = set( addDays( - hoveringDate && isSelectionStarted - ? maxDate([current?.[1], hoveringDate]) + currentHover && isSelectionStarted + ? maxDate([current?.[1], currentHover]) : current?.[1], 1 ), ZERO_TIME ); + const isSelected = Array.isArray(current) ? isAfter(day.date, prevDayRangeStart) && isBefore(day.date, nextDayRangeEnd) @@ -131,12 +171,36 @@ export const CalendarDays: FC = ({ Array.isArray(current) && isSameDay(addDays(nextDayRangeEnd, -1), day.date); + // Determine styling of range start and end dates + const hasNoRange = isStartRangeDate && isEndRangeDate; + const nextWeek = addDays(day.date, 7); + const nextWeekInRange = + isStartRangeDate && isBefore(nextWeek, nextDayRangeEnd); + const rangeConnectsBottom = + !nextWeekInRange && + (isSameMonth(day.date, nextWeek) || !hideNextMonthDays); + + const prevWeek = addDays(day.date, -7); + const prevWeekInRange = + isEndRangeDate && isAfter(prevWeek, prevDayRangeStart); + const rangeConnectsTop = + !prevWeekInRange && + (isSameMonth(day.date, prevWeek) || !hidePrevMonthDays); + // Determine the color variant of the button const colorVariant = isSelected ? 'primary' : 'default'; // Determine the button variant const buttonVariant = isSelected ? 'filled' : 'text'; + const handleHover = (value: Date | null) => { + if (onHover) { + onHover(value); + } else { + setHoveringDate(value); + } + }; + return ( ); }, - [disabled, minLimit, maxLimit, hoveringDate, current, isRange, onChange] + [ + disabled, + minLimit, + maxLimit, + hoveringDate, + current, + hover, + isRange, + onChange, + onHover + ] ); return ( @@ -174,6 +254,15 @@ export const CalendarDays: FC = ({ opacity: { duration: 0.2, type: animated ? 'tween' : false } }} > + {enableDayOfWeek && ( +
+ {DAY_OF_WEEK_LABELS.map(day => ( +
+ {day} +
+ ))} +
+ )} {weeks.map((week, i) => (
{week.map(renderDay)} diff --git a/src/form/Calendar/CalendarRange.tsx b/src/form/Calendar/CalendarRange.tsx new file mode 100644 index 000000000..9cbbd807f --- /dev/null +++ b/src/form/Calendar/CalendarRange.tsx @@ -0,0 +1,273 @@ +import { FC, Fragment, useCallback, useMemo, useState } from 'react'; +import { + add, + addMonths, + addYears, + endOfDecade, + getMonth, + getYear, + isSameDay, + max as maxDate, + min as minDate, + setMonth, + setYear, + startOfDecade, + sub, + subYears +} from 'date-fns'; +import { AnimatePresence, motion } from 'framer-motion'; +import { Button } from '../../elements/Button'; +import { CalendarProps, CalendarViewType } from './Calendar'; +import { CalendarDays } from './CalendarDays'; +import { CalendarMonths } from './CalendarMonths'; +import { CalendarYears } from './CalendarYears'; +import { DateFormat } from '../../data/DateFormat'; +import { SmallHeading } from '../../typography'; +import css from './Calendar.module.css'; + +export interface CalendarRangeProps extends CalendarProps { + /** + * The number of months to display in the range. + * Defaults to 2. + */ + numMonths?: number; +} + +export const CalendarRange: FC = ({ + min, + max, + value, + disabled, + isRange, + previousArrow, + nextArrow, + dateFormat, + animated, + onChange, + onViewChange, + numMonths, + enableDayOfWeek +}) => { + const date = useMemo( + () => (Array.isArray(value) ? value?.[0] : value) ?? new Date(), + [value] + ); + const rangeStart = useMemo( + () => value?.[0] ?? date ?? new Date(), + [date, value] + ); + const rangeEnd = useMemo( + () => value?.[1] ?? date ?? new Date(), + [date, value] + ); + + const [viewValue, setViewValue] = useState(date || new Date()); + const [monthValue, setMonthValue] = useState(getMonth(date)); + const [yearValue, setYearValue] = useState(getYear(date)); + const [decadeStart, setDecadeStart] = useState(startOfDecade(date)); + const [decadeEnd, setDecadeEnd] = useState(endOfDecade(date)); + const [view, setView] = useState('days'); + const [hoveringDate, setHoveringDate] = useState(null); + const [scrollDirection, setScrollDirection] = useState< + 'forward' | 'back' | null + >(null); + + if (numMonths < 0) { + return null; + } + + const displayMonths = Array.from(Array(numMonths).keys()); + + const previousClickHandler = useCallback(() => { + setScrollDirection('back'); + if (view === 'days') { + setViewValue(sub(viewValue, { months: 1 })); + } else if (view === 'months') { + setYearValue(yearValue - 1); + } else { + setDecadeStart(subYears(decadeStart, 10)); + setDecadeEnd(subYears(decadeEnd, 10)); + } + }, [decadeEnd, decadeStart, view, viewValue, yearValue]); + + const nextClickHandler = useCallback(() => { + setScrollDirection('forward'); + if (view === 'days') { + setViewValue(add(viewValue, { months: 1 })); + } else if (view === 'months') { + setYearValue(yearValue + 1); + } else { + setDecadeStart(addYears(decadeStart, 10)); + setDecadeEnd(addYears(decadeEnd, 10)); + } + }, [decadeEnd, decadeStart, view, viewValue, yearValue]); + + const headerClickHandler = useCallback(() => { + const newView = view === 'days' ? 'months' : 'years'; + setScrollDirection(null); + setView(newView); + onViewChange?.(newView); + }, [onViewChange, view]); + + const dateChangeHandler = useCallback( + (date: Date) => { + if (!isRange) { + onChange?.(date); + setMonthValue(getMonth(date)); + setYearValue(getYear(date)); + } else { + if (isSameDay(rangeStart, rangeEnd)) { + onChange?.([minDate([rangeStart, date]), maxDate([rangeEnd, date])]); + } else { + onChange?.([date, date]); + } + } + }, + [isRange, onChange, rangeEnd, rangeStart] + ); + + const monthsChangeHandler = useCallback( + month => { + setViewValue(setMonth(setYear(min || new Date(), yearValue), month)); + setMonthValue(month); + setView('days'); + onViewChange?.('days'); + }, + [min, yearValue, onViewChange] + ); + + const yearChangeHandler = useCallback( + year => { + setViewValue(setYear(min || new Date(), year)); + setYearValue(year); + setView('months'); + onViewChange?.('months'); + }, + [min, onViewChange] + ); + + const xAnimation = useMemo(() => { + switch (scrollDirection) { + case 'forward': + return '100%'; + case 'back': + return '-100%'; + default: + return 0; + } + }, [scrollDirection]); + + return ( +
+
+ + + +
+ + + {view === 'days' && + displayMonths.map(month => ( + + 0} + hideNextMonthDays={month < numMonths - 1} + enableDayOfWeek={enableDayOfWeek} + /> + + ))} + {view === 'months' && ( + + )} + {view === 'years' && ( + + )} + + +
+ ); +}; + +CalendarRange.defaultProps = { + previousArrow: '←', + nextArrow: '→', + animated: true, + dateFormat: 'MMMM yyyy', + range: [new Date(), new Date()], + numMonths: 2 +}; diff --git a/src/form/Calendar/index.ts b/src/form/Calendar/index.ts index a72338059..eb3ea071e 100644 --- a/src/form/Calendar/index.ts +++ b/src/form/Calendar/index.ts @@ -1 +1,2 @@ export * from './Calendar'; +export * from './CalendarRange';