From db4d10d34d3bca20fd35b0945854642662af48ff Mon Sep 17 00:00:00 2001 From: jongomez Date: Mon, 9 Oct 2023 11:32:22 +0100 Subject: [PATCH] feat: implement date range picker --- .../components/CSSBaseline/CSSBaseline.tsx | 2 + .../components/Calendar/Calendar.classes.ts | 13 +- .../components/Calendar/Calendar.context.ts | 9 + .../components/Calendar/Calendar.stories.tsx | 24 +- .../components/Calendar/Calendar.styles.ts | 55 +++-- .../src/components/Calendar/Calendar.tsx | 139 +++++++---- .../lsd-react/src/components/Calendar/Day.tsx | 100 ++++---- .../src/components/Calendar/Month.tsx | 166 +------------ .../src/components/Calendar/MonthHelpers.tsx | 188 +++++++++++++++ .../components/DateField/DateField.classes.ts | 1 + .../components/DateField/DateField.styles.ts | 25 +- .../src/components/DateField/DateField.tsx | 22 +- .../src/components/DatePicker/DatePicker.tsx | 27 ++- .../DateRangePicker.classes.ts | 22 ++ .../DateRangePicker.stories.tsx | 57 +++++ .../DateRangePicker/DateRangePicker.styles.ts | 161 +++++++++++++ .../DateRangePicker/DateRangePicker.tsx | 216 +++++++++++++++++ .../src/components/DateRangePicker/index.ts | 1 + packages/lsd-react/src/index.ts | 1 + packages/lsd-react/src/utils/date.utils.ts | 223 +++++++++++++++++- packages/lsd-react/src/utils/dom.util.ts | 10 + 21 files changed, 1172 insertions(+), 290 deletions(-) create mode 100644 packages/lsd-react/src/components/Calendar/MonthHelpers.tsx create mode 100644 packages/lsd-react/src/components/DateRangePicker/DateRangePicker.classes.ts create mode 100644 packages/lsd-react/src/components/DateRangePicker/DateRangePicker.stories.tsx create mode 100644 packages/lsd-react/src/components/DateRangePicker/DateRangePicker.styles.ts create mode 100644 packages/lsd-react/src/components/DateRangePicker/DateRangePicker.tsx create mode 100644 packages/lsd-react/src/components/DateRangePicker/index.ts create mode 100644 packages/lsd-react/src/utils/dom.util.ts diff --git a/packages/lsd-react/src/components/CSSBaseline/CSSBaseline.tsx b/packages/lsd-react/src/components/CSSBaseline/CSSBaseline.tsx index d8cba3d..ed6344b 100644 --- a/packages/lsd-react/src/components/CSSBaseline/CSSBaseline.tsx +++ b/packages/lsd-react/src/components/CSSBaseline/CSSBaseline.tsx @@ -42,6 +42,7 @@ import { ModalBodyStyles } from '../ModalBody/ModalBody.styles' import { ToastStyles } from '../Toast/Toast.styles' import { ToastProviderStyles } from '../ToastProvider/ToastProvider.styles' import { ButtonGroupStyles } from '../ButtonGroup/ButtonGroup.styles' +import { DateRangePickerStyles } from '../DateRangePicker/DateRangePicker.styles' const componentStyles: Array | SerializedStyles> = [ @@ -86,6 +87,7 @@ const componentStyles: Array | SerializedStyles> = ToastStyles, ToastProviderStyles, ButtonGroupStyles, + DateRangePickerStyles, ] export const CSSBaseline: React.FC<{ theme?: Theme }> = ({ diff --git a/packages/lsd-react/src/components/Calendar/Calendar.classes.ts b/packages/lsd-react/src/components/Calendar/Calendar.classes.ts index 0e1e79a..29478ef 100644 --- a/packages/lsd-react/src/components/Calendar/Calendar.classes.ts +++ b/packages/lsd-react/src/components/Calendar/Calendar.classes.ts @@ -6,7 +6,6 @@ export const calendarClasses = { disabled: 'lsd-calendar--disabled', header: 'lsd-calendar-header', - grid: 'lsd-calendar-body', weekDay: 'lsd-calendar__week_day', button: 'lsd-calendar__button', row: 'lsd-calendar__row', @@ -16,9 +15,19 @@ export const calendarClasses = { year: 'lsd-calendar-year', month: 'lsd-calendar-month', day: 'lsd-calendar-day', + + dayContainer: 'lsd-calendar-day__container', + dayRange: 'lsd-calendar-day--range', daySelected: 'lsd-calendar-day--selected', dayDisabled: 'lsd-calendar-day--disabled', dayIsToday: 'lsd-calendar-day--today', + + dayBorderLeft: 'lsd-calendar-day--border-left', + dayBorderRight: 'lsd-calendar-day--border-right', + dayBorderLeftAndRight: 'lsd-calendar-day--border-left-and-right', + dayBorderTopAndBottom: 'lsd-calendar-day--border-up-and-down', + todayIndicator: 'lsd-calendar-day__today_indicator', - dayRange: 'lsd-calendar-day--range', + + monthTable: 'lsd-calendar__month-table', } diff --git a/packages/lsd-react/src/components/Calendar/Calendar.context.ts b/packages/lsd-react/src/components/Calendar/Calendar.context.ts index 46cad87..4e0e84c 100644 --- a/packages/lsd-react/src/components/Calendar/Calendar.context.ts +++ b/packages/lsd-react/src/components/Calendar/Calendar.context.ts @@ -3,6 +3,9 @@ import React from 'react' export type CalendarContextType = { focusedDate: Date | null size?: 'large' | 'medium' | 'small' + mode?: 'date' | 'range' + startDate: Date | null + endDate: Date | null isDateFocused: (date: Date) => boolean isDateSelected: (date: Date) => boolean isDateHovered: (date: Date) => boolean @@ -11,6 +14,12 @@ export type CalendarContextType = { onDateFocus: (date: Date) => void onDateHover: (date: Date) => void onDateSelect: (date: Date) => void + goToPreviousMonths: () => void + goToNextMonths: () => void + goToNextYear: () => void + goToPreviousYear: () => void + changeYearMode: boolean + setChangeYearMode: (value: boolean) => void } export const CalendarContext = React.createContext( diff --git a/packages/lsd-react/src/components/Calendar/Calendar.stories.tsx b/packages/lsd-react/src/components/Calendar/Calendar.stories.tsx index 0041775..2b04188 100644 --- a/packages/lsd-react/src/components/Calendar/Calendar.stories.tsx +++ b/packages/lsd-react/src/components/Calendar/Calendar.stories.tsx @@ -28,12 +28,6 @@ export const Uncontrolled: StoryObj = { ) }, - - args: { - value: undefined, - onChange: undefined, - size: 'large', - }, } export const Controlled: StoryObj = { @@ -48,10 +42,16 @@ export const Controlled: StoryObj = { ) }, - - args: { - value: '2023-01-01', - onChange: undefined, - size: 'large', - }, +} + +Uncontrolled.args = { + startDate: undefined, + onStartDateChange: undefined, + size: 'large', +} + +Controlled.args = { + startDate: '2023-01-01', + onStartDateChange: undefined, + size: 'large', } diff --git a/packages/lsd-react/src/components/Calendar/Calendar.styles.ts b/packages/lsd-react/src/components/Calendar/Calendar.styles.ts index eb09c4b..5a185de 100644 --- a/packages/lsd-react/src/components/Calendar/Calendar.styles.ts +++ b/packages/lsd-react/src/components/Calendar/Calendar.styles.ts @@ -17,9 +17,9 @@ export const CalendarStyles = css` } .${calendarClasses.container} { - display: grid; + display: flex; + flex-direction: column; margin: 8px 2px 2px; - grid-gap: 0 64px; } .${calendarClasses.open} { @@ -35,19 +35,11 @@ export const CalendarStyles = css` padding-bottom: 10px; } - .${calendarClasses.grid} { - display: grid; - grid-template-columns: repeat(7, 1fr); - justify-content: center; - cursor: pointer; - } - .${calendarClasses.weekDay} { - text-align: center; - aspect-ratio: 1 / 1; display: flex; justify-content: center; align-items: center; + aspect-ratio: 1 / 1; } .${calendarClasses.row} { @@ -86,12 +78,22 @@ export const CalendarStyles = css` text-decoration-color: rgb(var(--lsd-border-primary)); } - .${calendarClasses.day} { + .${calendarClasses.dayContainer} { cursor: pointer; - border: none; background: transparent; - aspect-ratio: 1 / 1; + position: relative; + box-sizing: border-box; + + /* Prevents layout shifts when we add borders to the days */ + border: 1px solid transparent; + } + + .${calendarClasses.day} { + aspect-ratio: 1 / 1; + display: flex; + justify-content: center; + align-items: center; } .${calendarClasses.day}:hover { @@ -144,4 +146,29 @@ export const CalendarStyles = css` align-items: center; justify-content: center; } + + /* Using style double instead of solid. When collapsing borders, */ + /* this prevents the transparent borders from overriding the other borders. */ + /* This happens because the 'double' style is more specific than the 'solid' style */ + .${calendarClasses.dayBorderLeft} { + border-left: 1px double rgb(var(--lsd-border-primary)); + } + + .${calendarClasses.dayBorderRight} { + border-right: 1px double rgb(var(--lsd-border-primary)); + } + + .${calendarClasses.dayBorderLeftAndRight} { + border-left: 1px double rgb(var(--lsd-border-primary)); + border-right: 1px double rgb(var(--lsd-border-primary)); + } + + .${calendarClasses.dayBorderTopAndBottom} { + border-top: 1px double rgb(var(--lsd-border-primary)); + border-bottom: 1px double rgb(var(--lsd-border-primary)); + } + + .${calendarClasses.monthTable} { + border-collapse: collapse; + } ` diff --git a/packages/lsd-react/src/components/Calendar/Calendar.tsx b/packages/lsd-react/src/components/Calendar/Calendar.tsx index 2a3172e..3cbe6e6 100644 --- a/packages/lsd-react/src/components/Calendar/Calendar.tsx +++ b/packages/lsd-react/src/components/Calendar/Calendar.tsx @@ -6,68 +6,91 @@ import { import clsx from 'clsx' import React, { useEffect, useRef, useState } from 'react' import { useClickAway } from 'react-use' -import { safeConvertDateToString } from '../../utils/date.utils' import { calendarClasses } from './Calendar.classes' import { CalendarContext } from './Calendar.context' import { Month } from './Month' +import { getNewDates, isSameDay, safeConvertDate } from '../../utils/date.utils' +import { + CommonProps, + useCommonProps, + omitCommonProps, +} from '../../utils/useCommonProps' -export type CalendarProps = Omit< - React.HTMLAttributes, - 'label' | 'onChange' -> & { - open?: boolean - disabled?: boolean - value?: string - onChange: (data: Date) => void - handleRef: React.RefObject - size?: 'large' | 'medium' | 'small' - onClose?: () => void - onCalendarClickaway?: (event: Event) => void - minDate?: Date - maxDate?: Date -} +export type CalendarType = null | 'endDate' | 'startDate' + +export type CalendarProps = CommonProps & + Omit, 'label' | 'onChange'> & { + open?: boolean + disabled?: boolean + calendarType?: CalendarType + onStartDateChange?: (startDate: Date) => void + onEndDateChange?: (endDate: Date) => void + handleRef: React.RefObject + size?: 'large' | 'medium' | 'small' + mode?: 'date' | 'range' + onClose?: () => void + onCalendarClickaway?: (event: Event) => void + startDate: string + endDate?: string + minDate?: Date + maxDate?: Date + } export const Calendar: React.FC & { classes: typeof calendarClasses } = ({ open, handleRef, - value: valueProp, size = 'large', + mode = 'date', disabled = false, - onChange, + onStartDateChange, + onEndDateChange, onClose, onCalendarClickaway, + startDate: startDateProp, + endDate: endDateProp, + calendarType = 'startDate', // minDate and maxDate are necessary because onDateFocus freaks out with small/large date values. - minDate = new Date(1900, 0, 1), + minDate = new Date(1950, 0, 1), maxDate = new Date(2100, 0, 1), - children, ...props }) => { + const commonProps = useCommonProps(props) const ref = useRef(null) const [style, setStyle] = useState({}) - const [value, setValue] = useState( - valueProp - ? safeConvertDateToString(valueProp, minDate, maxDate).date + const [startDate, setStartDate] = useState( + startDateProp + ? safeConvertDate(startDateProp, minDate, maxDate).date : null, ) - const isOpenControlled = typeof open !== 'undefined' + const [endDate, setEndDate] = useState( + endDateProp ? safeConvertDate(endDateProp, minDate, maxDate).date : null, + ) + const [changeYearMode, setChangeYearMode] = useState(false) useClickAway(ref, (event) => { if (!open) return onCalendarClickaway && onCalendarClickaway(event) - - if (isOpenControlled) return - - onClose && onClose() + if (typeof open === 'undefined') { + onClose && onClose() + } }) const handleDateChange = (data: OnDatesChangeProps) => { - if (typeof valueProp !== 'undefined') - return onChange?.(data.startDate ?? new Date()) + const newDates = getNewDates(calendarType, startDate, endDate, data) + const { newStartDate, newEndDate } = newDates - setValue(data.startDate) + if (newStartDate !== startDate) { + onStartDateChange?.(newStartDate ?? new Date()) + setStartDate(newStartDate) + } + + if (newEndDate !== endDate && mode === 'range') { + onEndDateChange?.(newEndDate ?? new Date()) + setEndDate(newEndDate) + } } const { @@ -83,29 +106,52 @@ export const Calendar: React.FC & { onDateFocus, goToPreviousMonths, goToNextMonths, + goToNextYear, + goToPreviousYear, } = useDatepicker({ - startDate: value ? new Date(value) : null, - endDate: null, + startDate, + endDate, + // focusedInput is meant to define which is currently selected. However, + // that's not why we're setting it here. We're setting it here just because it's a required arg. focusedInput: START_DATE, onDatesChange: handleDateChange, numberOfMonths: 1, }) + // Handle startDateProp and endDateProp changes. Only updates them if they differ from current state. useEffect(() => { - onDateFocus(value ? new Date(value) : new Date()) - }, [value]) + const newStart = safeConvertDate(startDateProp, minDate, maxDate) + + if (!isSameDay(newStart.date, startDate)) { + setStartDate(newStart.isValid ? newStart.date : null) + } + + if (mode === 'range') { + const newEnd = safeConvertDate(endDateProp, minDate, maxDate) + + if (!isSameDay(newEnd.date, endDate)) { + setEndDate(newEnd.isValid ? newEnd.date : null) + } + } + }, [startDateProp, endDateProp, mode, minDate, maxDate, startDate, endDate]) useEffect(() => { - if (typeof valueProp === 'undefined') return + // When the startDate state changes, focus the calendar on that date. + if (startDate) { + onDateFocus(startDate) + } + }, [startDate]) - const { date } = safeConvertDateToString(valueProp, minDate, maxDate) - setValue(date) - }, [valueProp]) + useEffect(() => { + // When the endDate state changes, focus the calendar on that date. + if (endDate) { + onDateFocus(endDate) + } + }, [endDate]) const updateStyle = () => { const { width, height, top, left } = handleRef.current!.getBoundingClientRect() - setStyle({ left, width, @@ -121,6 +167,9 @@ export const Calendar: React.FC & { & { onDateSelect, onDateFocus, onDateHover, + goToPreviousMonths, + goToNextMonths, + goToNextYear, + goToPreviousYear, + changeYearMode, + setChangeYearMode, }} >
& { month={month.month} firstDayOfWeek={0} size={size} - goToPreviousMonths={goToPreviousMonths} - goToNextMonths={goToNextMonths} /> ))}
diff --git a/packages/lsd-react/src/components/Calendar/Day.tsx b/packages/lsd-react/src/components/Calendar/Day.tsx index f5120f4..8acb91a 100644 --- a/packages/lsd-react/src/components/Calendar/Day.tsx +++ b/packages/lsd-react/src/components/Calendar/Day.tsx @@ -1,72 +1,80 @@ -import { useRef, useContext } from 'react' +import { useRef } from 'react' import { useDay } from '@datepicker-react/hooks' -import { CalendarContext } from './Calendar.context' +import { useCalendarContext } from './Calendar.context' import clsx from 'clsx' -import { calendarClasses } from './Calendar.classes' import { Typography } from '../Typography' +import { + getDayBorders, + isDateWithinRange, + isSameDay, + resetHours, +} from '../../utils/date.utils' +import { calendarClasses } from './Calendar.classes' export type DayProps = { day?: string - date: Date + index: number + fullMonthDays: Date[] disabled?: boolean } -export const Day = ({ day, date, disabled = false }: DayProps) => { +export const Day = ({ + day, + index, + fullMonthDays, + disabled = false, +}: DayProps) => { + const date = fullMonthDays[index] + const { mode, startDate, endDate, ...calendarContext } = useCalendarContext() const dayRef = useRef(null) - const { - focusedDate, - isDateFocused, - isDateSelected, - isDateHovered, - isDateBlocked, - isFirstOrLastSelectedDate, - onDateSelect, - onDateFocus, - onDateHover, - } = useContext(CalendarContext) + const dayHandlers = useDay({ date, dayRef, ...calendarContext }) + const isToday = resetHours(date) === resetHours(new Date()) + const isInDateRange = + mode === 'range' && isDateWithinRange(date, startDate, endDate) - const { onClick, onKeyDown, onMouseEnter, tabIndex } = useDay({ - date, - focusedDate, - isDateFocused, - isDateSelected, - isDateHovered, - isDateBlocked, - isFirstOrLastSelectedDate, - onDateFocus, - onDateSelect, - onDateHover, - dayRef, - }) + const isStartDate = isSameDay(date, startDate) + const isEndDate = mode === 'range' && isSameDay(date, endDate) + const isSelected = isStartDate || isEndDate || isInDateRange if (!day) { return null } - const isToday = - new Date(date).setHours(0, 0, 0, 0) === new Date().setHours(0, 0, 0, 0) + const borderClasses = getDayBorders( + index, + fullMonthDays, + isSelected, + startDate, + endDate, + ) return ( - +
+ {parseInt(day, 10)} + {isToday && ( + + ▬ + + )} +
+ ) } diff --git a/packages/lsd-react/src/components/Calendar/Month.tsx b/packages/lsd-react/src/components/Calendar/Month.tsx index 4b3ab10..a8533e5 100644 --- a/packages/lsd-react/src/components/Calendar/Month.tsx +++ b/packages/lsd-react/src/components/Calendar/Month.tsx @@ -1,181 +1,41 @@ import { FirstDayOfWeek, useMonth } from '@datepicker-react/hooks' -import clsx from 'clsx' -import { useRef, useState } from 'react' -import { useClickAway } from 'react-use' -import { IconButton } from '../IconButton' -import { - ChevronDownIcon, - ChevronUpIcon, - ChevronLeftIcon, - ChevronRightIcon, -} from '../Icons' -import { Typography } from '../Typography' -import { calendarClasses } from './Calendar.classes' +import { useState } from 'react' import { useCalendarContext } from './Calendar.context' -import { Day } from './Day' +import { Days, MonthHeader, WeekdayHeader } from './MonthHelpers' +import { calendarClasses } from './Calendar.classes' export type MonthProps = { year: number month: number firstDayOfWeek: FirstDayOfWeek - goToPreviousMonths: () => void - goToNextMonths: () => void size?: 'large' | 'medium' | 'small' } export const Month = ({ size: _size = 'large', - year: _year, + year, month, firstDayOfWeek, - goToPreviousMonths, - goToNextMonths, }: MonthProps) => { const sizeContext = useCalendarContext() const size = sizeContext?.size ?? _size - const [year, setYear] = useState(_year) const { days, weekdayLabels, monthLabel } = useMonth({ year, month, firstDayOfWeek, }) - const [changeYear, setChangeYear] = useState(false) - const ref = useRef(null) - - const renderOtherDays = (idx: number, referenceDate: Date) => { - const date = new Date(referenceDate) - date.setDate(date.getDate() + idx) - return date.getDate() - } - - const getDay = (index: number) => - days[index] as { - dayLabel: string - date: Date - } - - useClickAway(ref, (event) => { - if (!changeYear) return - - setChangeYear(false) - }) return ( <> -
- -
- - {monthLabel.split(' ')[0]} - - {changeYear ? ( -
- - {monthLabel.split(' ')[1]} - -
- setYear(year + 1)} - className={calendarClasses.changeYearButton} - > - - - setYear(year - 1)} - className={calendarClasses.changeYearButton} - > - - -
-
- ) : ( - setChangeYear(true)} - variant={size === 'large' ? 'label1' : 'label2'} - className={calendarClasses.year} - > - {monthLabel.split(' ')[1]} - - )} -
- -
-
- {weekdayLabels.map((dayLabel, idx) => ( - - {dayLabel[0]} - - ))} -
-
- {days.length == 28 && - new Array(7) - .fill(null) - .map((_, idx) => ( - - ))} - {days.map((ele, idx) => - typeof ele !== 'number' ? ( - - ) : ( - day === 0).length, - getDay(days.lastIndexOf(0) + 1).date, - ).toString()} - key={`current-${idx}`} - disabled={true} - /> - ), - )} - {new Array( - days.length % 7 !== 0 && days.length <= 35 - ? 7 - (days.length % 7) + 7 - : 7 - (days.length % 7), - ) - .fill(null) - .map((ele, idx) => ( - - ))} -
+ + + + + + + + +
) } diff --git a/packages/lsd-react/src/components/Calendar/MonthHelpers.tsx b/packages/lsd-react/src/components/Calendar/MonthHelpers.tsx new file mode 100644 index 0000000..3b36c16 --- /dev/null +++ b/packages/lsd-react/src/components/Calendar/MonthHelpers.tsx @@ -0,0 +1,188 @@ +import clsx from 'clsx' +import { Day } from './Day' +import { FC, useRef } from 'react' +import { + ArrowDownIcon, + ArrowUpIcon, + NavigateBeforeIcon, + NavigateNextIcon, +} from '../Icons' +import { calendarClasses } from './Calendar.classes' +import { Typography } from '../Typography' +import { IconButton } from '../IconButton' +import { UseMonthResult } from '@datepicker-react/hooks' +import { useClickAway } from 'react-use' +import { generateFullMonthDays } from '../../utils/date.utils' +import { useCalendarContext } from './Calendar.context' + +type CalendarNavigationButtonProps = { + direction: 'previous' | 'next' + onClick: () => void +} + +export const CalendarNavigationButton: FC = ({ + direction, + onClick, +}) => { + const Icon = direction === 'previous' ? NavigateBeforeIcon : NavigateNextIcon + return ( + + ) +} + +type YearControlProps = { + year: string + size: 'large' | 'medium' | 'small' + onClickAway: () => void +} + +export const YearControl: FC = ({ + year, + size, + onClickAway, +}) => { + const ref = useRef(null) + const { goToNextYear, goToPreviousYear } = useCalendarContext() + + useClickAway(ref, () => { + onClickAway() + }) + + return ( +
+ + {year} + +
+ { + goToNextYear() + }} + className={calendarClasses.changeYearButton} + > + + + { + goToPreviousYear() + }} + className={calendarClasses.changeYearButton} + > + + +
+
+ ) +} + +type MonthHeaderProps = { + monthLabel: string + size: 'large' | 'medium' | 'small' +} + +export const MonthHeader: FC = ({ monthLabel, size }) => { + const { + goToPreviousMonths, + goToNextMonths, + changeYearMode, + setChangeYearMode, + } = useCalendarContext() + const [month, year] = monthLabel.split(' ') + + return ( +
+ +
+ + {month} + + {changeYearMode ? ( + { + setChangeYearMode(false) + }} + /> + ) : ( + setChangeYearMode(true)} + variant={size === 'large' ? 'label1' : 'label2'} + className={calendarClasses.year} + > + {year} + + )} +
+ +
+ ) +} + +type WeekdayHeaderProps = { + weekdayLabels: string[] +} + +export const WeekdayHeader: FC = ({ weekdayLabels }) => { + return ( + + {weekdayLabels.map((dayLabel, idx) => ( + +
+ {dayLabel[0]} +
+ + ))} + + ) +} + +type DaysProps = { + days: UseMonthResult['days'] +} + +export const Days: FC = ({ days }) => { + const fullMonthDays = generateFullMonthDays(days) + // Fetch the current month from day 15 of the array - this day is guaranteed to be in the current month + const currentMonth = new Date(fullMonthDays[15]).getMonth() + + return ( + <> + {Array.from({ length: 6 }).map((_, weekIdx) => ( + + {Array.from({ length: 7 }).map((_, dayIdx) => { + const index = weekIdx * 7 + dayIdx + const date = fullMonthDays[index] + + return ( + + ) + })} + + ))} + + ) +} diff --git a/packages/lsd-react/src/components/DateField/DateField.classes.ts b/packages/lsd-react/src/components/DateField/DateField.classes.ts index 1f72540..e2b5721 100644 --- a/packages/lsd-react/src/components/DateField/DateField.classes.ts +++ b/packages/lsd-react/src/components/DateField/DateField.classes.ts @@ -6,6 +6,7 @@ export const dateFieldClasses = { input: `lsd-date-field__input-container__input`, inputFilled: `lsd-date-field__input-container__input--filled`, icon: `lsd-date-field__input-container__icon`, + noIcon: `lsd-date-field__input-container__no-icon`, iconButton: `lsd-date-field__input-container__icon-button`, supportingText: 'lsd-date-field__supporting-text', diff --git a/packages/lsd-react/src/components/DateField/DateField.styles.ts b/packages/lsd-react/src/components/DateField/DateField.styles.ts index b7d6538..dcb0e92 100644 --- a/packages/lsd-react/src/components/DateField/DateField.styles.ts +++ b/packages/lsd-react/src/components/DateField/DateField.styles.ts @@ -12,9 +12,26 @@ export const DateFieldStyles = css` } .${dateFieldClasses.icon} { + position: absolute; + right: 0; + cursor: pointer; display: flex; align-items: center; + background-color: rgb(var(--lsd-surface-primary)); + } + + .${dateFieldClasses.icon}:focus { + background: blue; + background-color: pink; + border: 1px solid rgb(var(--lsd-border-primary)); + } + + .${dateFieldClasses.noIcon} { + position: absolute; + right: 0; + background-color: rgb(var(--lsd-surface-primary)); + padding: 12px; } .${dateFieldClasses.outlined} { @@ -26,6 +43,9 @@ export const DateFieldStyles = css` } .${dateFieldClasses.inputContainer} { + // Position relative allows the icons to be absolute positioned... + // ... and the icons should be absolute positioned to be on top of the browser's default icons. + position: relative; display: flex; align-items: center; justify-content: space-between; @@ -70,7 +90,7 @@ export const DateFieldStyles = css` height: 40px; } .${dateFieldClasses.input} { - padding: 9px 13px 9px 17px; + padding: 9px 0px 9px 17px; } .${dateFieldClasses.icon} { padding: 12px 13px; @@ -82,6 +102,7 @@ export const DateFieldStyles = css` .${dateFieldClasses.medium} { width: 188px; + .${dateFieldClasses.label} { margin: 0 0 6px 14px; } @@ -101,6 +122,7 @@ export const DateFieldStyles = css` .${dateFieldClasses.small} { width: 164px; + .${dateFieldClasses.label} { margin: 0 0 6px 12px; } @@ -109,6 +131,7 @@ export const DateFieldStyles = css` } .${dateFieldClasses.input} { padding: 5px 9px 5px 11px; + font-size: 12px; } .${dateFieldClasses.icon} { padding: 6px 9px; diff --git a/packages/lsd-react/src/components/DateField/DateField.tsx b/packages/lsd-react/src/components/DateField/DateField.tsx index 1d09536..b2fe1d4 100644 --- a/packages/lsd-react/src/components/DateField/DateField.tsx +++ b/packages/lsd-react/src/components/DateField/DateField.tsx @@ -4,11 +4,14 @@ import { useInput } from '../../utils/useInput' import { CloseIcon, ErrorIcon } from '../Icons' import { Typography } from '../Typography' import { dateFieldClasses } from './DateField.classes' +import { + CommonProps, + useCommonProps, + omitCommonProps, +} from '../../utils/useCommonProps' -export type DateFieldProps = Omit< - React.HTMLAttributes, - 'onChange' | 'value' -> & +export type DateFieldProps = CommonProps & + Omit, 'onChange' | 'value'> & Pick, 'onChange'> & { label?: React.ReactNode size?: 'large' | 'medium' | 'small' @@ -45,9 +48,11 @@ export const DateField: React.FC & { icon, onIconClick, inputProps = {}, + calendarIconRef, variant = 'underlined', ...props }) => { + const commonProps = useCommonProps(props) const ref = useRef(null) const input = useInput({ defaultValue, @@ -65,7 +70,9 @@ export const DateField: React.FC & { aria-disabled={disabled ? 'true' : 'false'} {...props} className={clsx( + { ...omitCommonProps(props) }, props.className, + commonProps.className, dateFieldClasses.root, dateFieldClasses[size], disabled && dateFieldClasses.disabled, @@ -109,7 +116,7 @@ export const DateField: React.FC & { !disabled && onIconClick && onIconClick()} - ref={props.calendarIconRef} + ref={calendarIconRef} > {icon} @@ -124,7 +131,10 @@ export const DateField: React.FC & { > - ) : null} + ) : ( + // Default case: just show and empty span on top of the browser's default icon. + + )} {supportingText && (
diff --git a/packages/lsd-react/src/components/DatePicker/DatePicker.tsx b/packages/lsd-react/src/components/DatePicker/DatePicker.tsx index 73dfb48..b2ee972 100644 --- a/packages/lsd-react/src/components/DatePicker/DatePicker.tsx +++ b/packages/lsd-react/src/components/DatePicker/DatePicker.tsx @@ -10,11 +10,17 @@ import { DateField } from '../DateField' import { CalendarIcon } from '../Icons' import { Portal } from '../PortalProvider/Portal' import { datePickerClasses } from './DatePicker.classes' +import { wasElementClicked } from '../../utils/dom.util' -export type DatePickerProps = Omit< - React.HTMLAttributes, - 'onChange' | 'value' -> & +import { + CommonProps, + omitCommonProps, + pickCommonProps, + useCommonProps, +} from '../../utils/useCommonProps' + +export type DatePickerProps = CommonProps & + Omit, 'onChange' | 'value'> & Pick, 'onChange'> & { label?: React.ReactNode error?: boolean @@ -42,6 +48,7 @@ export const DatePicker: React.FC & { variant = 'underlined', ...props }) => { + const commonProps = useCommonProps(props) const ref = useRef(null) const calendarIconRef = useRef(null) const [openCalendar, setOpenCalendar] = useState(false) @@ -68,7 +75,9 @@ export const DatePicker: React.FC & { ref={ref} {...props} className={clsx( + { ...omitCommonProps(props) }, props.className, + commonProps.className, datePickerClasses.root, datePickerClasses[size], )} @@ -88,22 +97,20 @@ export const DatePicker: React.FC & { {withCalendar && ( handleDateChange(date)} + {...pickCommonProps(props)} + onStartDateChange={(date) => handleDateChange(date)} open={openCalendar} onCalendarClickaway={(event) => { // If the calendar icon was clicked, return and don't close the calendar here. // Let the onIconClick above handle the closing. - if ( - calendarIconRef.current && - event?.composedPath().includes(calendarIconRef.current) - ) { + if (wasElementClicked(event, calendarIconRef.current)) { return } setOpenCalendar(false) }} handleRef={ref} - value={input.value} + startDate={input.value} disabled={props.disabled} className={datePickerClasses.calendar} /> diff --git a/packages/lsd-react/src/components/DateRangePicker/DateRangePicker.classes.ts b/packages/lsd-react/src/components/DateRangePicker/DateRangePicker.classes.ts new file mode 100644 index 0000000..5073a1c --- /dev/null +++ b/packages/lsd-react/src/components/DateRangePicker/DateRangePicker.classes.ts @@ -0,0 +1,22 @@ +export const dateRangePickerClasses = { + root: `lsd-date-range-picker`, + + calendar: `lsd-date-picker__calendar`, + + withCalendar: `lsd-date-range-picker--with-calendar`, + openCalendar: `lsd-date-range-picker--calendar-open`, + disabled: `lsd-date-range-picker--disabled`, + inputContainer: `lsd-date-range-picker__input-container`, + + icon: `lsd-date-range-picker__icon`, + + large: `lsd-date-range-picker--large`, + medium: `lsd-date-range-picker--medium`, + small: `lsd-date-range-picker--small`, + + label: 'lsd-date-range-picker__label', + supportingText: 'lsd-date-range-picker__supporting-text', + + outlined: 'lsd-date-range-picker--outlined', + separator: 'lsd-date-range-picker__separator', +} diff --git a/packages/lsd-react/src/components/DateRangePicker/DateRangePicker.stories.tsx b/packages/lsd-react/src/components/DateRangePicker/DateRangePicker.stories.tsx new file mode 100644 index 0000000..2d18be5 --- /dev/null +++ b/packages/lsd-react/src/components/DateRangePicker/DateRangePicker.stories.tsx @@ -0,0 +1,57 @@ +import { Meta, Story } from '@storybook/react' +import { DateRangePicker, DateRangePickerProps } from './DateRangePicker' + +export default { + title: 'DateRangePicker', + component: DateRangePicker, + argTypes: { + size: { + type: { + name: 'enum', + value: ['small', 'medium', 'large'], + }, + defaultValue: 'large', + }, + variant: { + type: { + name: 'enum', + value: ['outlined', 'outlined-bottom'], + }, + defaultValue: 'large', + }, + }, +} as Meta + +export const Uncontrolled: Story = ({ ...args }) => { + return +} + +export const Controlled: Story = ({ ...args }) => { + return +} + +Uncontrolled.args = { + supportingText: 'Supporting text', + label: 'Label', + disabled: false, + error: false, + startValue: undefined, + endValue: undefined, + errorIcon: false, + withCalendar: true, + size: 'large', + variant: 'outlined-bottom', +} + +Controlled.args = { + supportingText: 'Supporting text', + label: 'Label', + disabled: false, + error: false, + startValue: '2023-01-02', + endValue: '2023-01-10', + errorIcon: false, + withCalendar: true, + size: 'large', + variant: 'outlined-bottom', +} diff --git a/packages/lsd-react/src/components/DateRangePicker/DateRangePicker.styles.ts b/packages/lsd-react/src/components/DateRangePicker/DateRangePicker.styles.ts new file mode 100644 index 0000000..0ea9a65 --- /dev/null +++ b/packages/lsd-react/src/components/DateRangePicker/DateRangePicker.styles.ts @@ -0,0 +1,161 @@ +import { css } from '@emotion/react' +import { dateRangePickerClasses } from './DateRangePicker.classes' +import { dateFieldClasses } from '../DateField/DateField.classes' + +export const DateRangePickerStyles = css` + .${dateRangePickerClasses.root} { + box-sizing: border-box; + } + + .${dateRangePickerClasses.label} { + display: block; + } + + .${dateRangePickerClasses.inputContainer} { + box-sizing: border-box; + + display: flex; + align-items: center; + /* Outlined & non outlined versions must have same sizes - borders included. */ + border: 1px solid transparent; + } + + .${dateRangePickerClasses.calendar} { + border-top: none !important; + } + + .${dateRangePickerClasses.openCalendar} { + .${dateRangePickerClasses.inputContainer} { + border-bottom: 1px solid rgb(var(--lsd-border-primary)); + } + } + + .${dateRangePickerClasses.icon} { + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-grow: 1; + padding: 0 10px; + } + + .${dateRangePickerClasses.disabled} { + opacity: 0.3; + } + + .${dateRangePickerClasses.supportingText} { + position: absolute; + } + + .${dateRangePickerClasses.large} { + width: 318px; + + .${dateFieldClasses.large} { + width: 156px; + } + + .${dateFieldClasses.input} { + padding-right: 0; + } + + .${dateFieldClasses.icon} { + padding: 11px 12px; + } + + .${dateRangePickerClasses.label} { + margin: 0 0 6px 18px; + } + + .${dateRangePickerClasses.inputContainer} { + height: 40px; + } + + .${dateRangePickerClasses.supportingText} { + margin: 6px 18px 0 18px; + } + } + + .${dateRangePickerClasses.medium} { + width: 290px; + + .${dateFieldClasses.medium} { + width: 142px; + } + + .${dateFieldClasses.input} { + padding-right: 0; + } + + .${dateFieldClasses.icon} { + padding: 7px 8px; + } + + .${dateRangePickerClasses.label} { + margin: 0 0 6px 14px; + } + + .${dateRangePickerClasses.inputContainer} { + height: 32px; + } + + .${dateRangePickerClasses.supportingText} { + margin: 6px 14px 0 14px; + } + } + + .${dateRangePickerClasses.small} { + width: 262px; + + .${dateFieldClasses.small} { + width: 128px; + } + + .${dateFieldClasses.input} { + padding-right: 0; + } + + .${dateFieldClasses.icon} { + padding: 5px 7px; + } + + .${dateRangePickerClasses.label} { + margin: 0 0 6px 12px; + } + + .${dateRangePickerClasses.inputContainer} { + height: 28px; + } + + .${dateRangePickerClasses.supportingText} { + margin: 6px 12px 0 12px; + } + } + + .${dateFieldClasses.icon} { + padding: 8px; + } + + /* Remove default outline styles from date fields. */ + .${dateFieldClasses.outlined} { + border: none; + } + + .${dateRangePickerClasses.separator} { + margin-left: 3px; + width: 1px; + height: 100%; + } + + .${dateRangePickerClasses.separator} { + /* Outlined & non outlined versions must have same sizes - borders included. */ + border-left: 1px solid transparent; + } + + .${dateRangePickerClasses.outlined} { + border: 1px solid rgb(var(--lsd-border-primary)); + + .${dateRangePickerClasses.separator} { + border-left: 1px solid rgb(var(--lsd-border-primary)); + } + } +` diff --git a/packages/lsd-react/src/components/DateRangePicker/DateRangePicker.tsx b/packages/lsd-react/src/components/DateRangePicker/DateRangePicker.tsx new file mode 100644 index 0000000..d098020 --- /dev/null +++ b/packages/lsd-react/src/components/DateRangePicker/DateRangePicker.tsx @@ -0,0 +1,216 @@ +import clsx from 'clsx' +import React, { ChangeEvent, ChangeEventHandler, useRef, useState } from 'react' +import { + dateToISODateString, + isValidRange, + removeDateTimezoneOffset, + switchCalendar, +} from '../../utils/date.utils' +import { useInput } from '../../utils/useInput' +import { Calendar, CalendarType } from '../Calendar' +import { DateField, DateFieldProps } from '../DateField' +import { CalendarIcon } from '../Icons' +import { Portal } from '../PortalProvider/Portal' +import { dateRangePickerClasses } from './DateRangePicker.classes' +import { wasElementClicked } from '../../utils/dom.util' +import { DatePickerProps } from '../DatePicker' +import { Typography } from '../Typography' +import { + CommonProps, + useCommonProps, + omitCommonProps, + pickCommonProps, +} from '../../utils/useCommonProps' + +export type DateRangePickerProps = CommonProps & + Omit & { + startValue?: string + endValue?: string + } + +export const DateRangePicker: React.FC & { + classes: typeof dateRangePickerClasses +} = ({ + startValue: startValueProp, + endValue: endValueProp, + onChange, + size = 'large', + variant = 'outlined-bottom', + withCalendar = true, + label, + supportingText, + disabled, + ...props +}) => { + const commonProps = useCommonProps(props) + const ref = useRef(null) + const endCalendarIconRef = useRef(null) + const startCalendarIconRef = useRef(null) + const [calendarType, setCalendarType] = useState(null) + const isStartValueControlled = typeof startValueProp !== 'undefined' + const isEndValueControlled = typeof endValueProp !== 'undefined' + + const startInput = useInput({ + value: startValueProp, + defaultValue: '', + onChange, + getInput: () => + ref.current?.querySelectorAll( + `input.${DateField.classes.input}`, + )[0] as HTMLInputElement, + }) + + const endInput = useInput({ + value: endValueProp, + defaultValue: '', + onChange, + getInput: () => + ref.current?.querySelectorAll( + `input.${DateField.classes.input}`, + )[1] as HTMLInputElement, + }) + + const onStartInputChange = (e: ChangeEvent) => { + if (!endInput.value || isValidRange(e.target.value, endInput.value)) { + startInput.onChange(e) + } + } + + const onEndInputChange = (e: ChangeEvent) => { + if (!startInput.value || isValidRange(startInput.value, e.target.value)) { + endInput.onChange(e) + } + } + + const calendarStartDateChange = (date: Date) => + startInput.setValue(dateToISODateString(removeDateTimezoneOffset(date))) + + const calendarEndDateChange = (date: Date) => + endInput.setValue(dateToISODateString(removeDateTimezoneOffset(date))) + + const dateFieldProps: DateFieldProps = { + ...props, + size, + label: undefined, + supportingText: undefined, + } + + const isStartDateCalendar = calendarType === 'startDate' + const isEndDateCalendar = calendarType === 'endDate' + const isCalendarOpen = isStartDateCalendar || isEndDateCalendar + + return ( +
+ {label && ( + + {label} + + )} + +
+ } + // The DateField component is only controlled when the value prop is provided OR the calendar is open. + value={ + isStartValueControlled || isStartDateCalendar + ? startInput.value + : undefined + } + onIconClick={() => + setCalendarType((currentCalendarType) => + switchCalendar(currentCalendarType, 'startDate'), + ) + } + onChange={onStartInputChange} + {...dateFieldProps} + /> + +
+ + } + // The DateField component is only controlled when the value prop is provided OR the calendar is open. + value={ + isEndValueControlled || isEndDateCalendar + ? endInput.value + : undefined + } + onIconClick={() => + setCalendarType((currentCalendarType) => + switchCalendar(currentCalendarType, 'endDate'), + ) + } + onChange={onEndInputChange} + {...dateFieldProps} + /> +
+ + {supportingText && ( +
+ + {supportingText} + +
+ )} + + {withCalendar && ( + + { + // If a calendar icon was clicked, return and don't close the calendar here. + // Let the calendar icon's onClick handle the closing / opening. + if ( + wasElementClicked(event, endCalendarIconRef.current) || + wasElementClicked(event, startCalendarIconRef.current) + ) { + return + } + + setCalendarType(null) + }} + calendarType={calendarType} + open={isCalendarOpen} + onClose={() => setCalendarType(null)} + handleRef={ref} + mode="range" + disabled={disabled} + startDate={startInput.value} + endDate={endInput.value} + className={dateRangePickerClasses.calendar} + /> + + )} +
+ ) +} + +DateRangePicker.classes = dateRangePickerClasses diff --git a/packages/lsd-react/src/components/DateRangePicker/index.ts b/packages/lsd-react/src/components/DateRangePicker/index.ts new file mode 100644 index 0000000..6eea8a4 --- /dev/null +++ b/packages/lsd-react/src/components/DateRangePicker/index.ts @@ -0,0 +1 @@ +export * from './DateRangePicker' diff --git a/packages/lsd-react/src/index.ts b/packages/lsd-react/src/index.ts index a3458c0..ecf70cc 100644 --- a/packages/lsd-react/src/index.ts +++ b/packages/lsd-react/src/index.ts @@ -40,3 +40,4 @@ export * from './components/Calendar' export * from './components/Toast' export * from './components/ToastProvider' export * from './components/ButtonGroup' +export * from './components/DateRangePicker' diff --git a/packages/lsd-react/src/utils/date.utils.ts b/packages/lsd-react/src/utils/date.utils.ts index 6b8f977..ae79f46 100644 --- a/packages/lsd-react/src/utils/date.utils.ts +++ b/packages/lsd-react/src/utils/date.utils.ts @@ -1,14 +1,24 @@ -export const safeConvertDateToString = ( - value: string, +import { OnDatesChangeProps, UseMonthResult } from '@datepicker-react/hooks' +import { calendarClasses } from '../components/Calendar/Calendar.classes' +import { CalendarType } from '../components/Calendar' + +type SafeConvertDateResult = { + isValid: boolean + date: Date | null +} + +export const safeConvertDate = ( + value: string | undefined, minDate: Date, maxDate: Date, -) => { +): SafeConvertDateResult => { + if (!value) return { isValid: false, date: null } const date = new Date(value ?? undefined) const isValid = !Number.isNaN(+date) && date >= minDate && date <= maxDate return { isValid, - date: isValid ? date : new Date(), + date, } } export const removeDateTimezoneOffset = (date: Date) => @@ -16,3 +26,208 @@ export const removeDateTimezoneOffset = (date: Date) => export const dateToISODateString = (date: Date) => date.toISOString().split('T')[0] + +export const resetHours = (date: Date) => date.setHours(0, 0, 0, 0) + +export const isDateWithinRange = ( + date: Date | undefined, + start: Date | null, + end: Date | null, +): boolean => { + if (!date || !start || !end) return false + + return ( + resetHours(start) <= resetHours(date) && resetHours(end) >= resetHours(date) + ) +} + +export const isSameDay = ( + date: Date | null | undefined, + start: Date | null | undefined, +): boolean => { + if (!date || !start) return false + + return resetHours(date) === resetHours(start) +} + +type NewDates = { + newStartDate: Date | null + newEndDate: Date | null +} + +export const getNewDates = ( + calendarType: CalendarType | null, + startDateState: Date | null, + endDateState: Date | null, + dateChangeProps: OnDatesChangeProps, +): NewDates => { + let newStartDate = startDateState + let newEndDate = endDateState + const selectedDate = dateChangeProps.startDate + + if (!selectedDate) { + return { + newStartDate, + newEndDate, + } + } + + if (calendarType === 'startDate') { + if ( + !newEndDate || + (newEndDate && selectedDate.getTime() <= newEndDate.getTime()) + ) { + newStartDate = selectedDate + } + } else if (calendarType === 'endDate') { + if ( + !newStartDate || + (newStartDate && selectedDate.getTime() >= newStartDate.getTime()) + ) { + newEndDate = selectedDate + } + } + + return { + newStartDate, + newEndDate, + } +} + +// In the days array, days from the current month are represented as objects with a date property. +// Days from the previous month are represented as numbers. +// This function finds and returns the first object (i.e. a day from the current month). +const getStartDateOfMonth = (days: UseMonthResult['days']): Date => { + // Find the first valid day + const firstValidDay = days.find((day) => typeof day !== 'number') + return firstValidDay ? new Date((firstValidDay as any).date) : new Date() +} + +export const generateFullMonthDays = (days: UseMonthResult['days']): Date[] => { + const startDateOfMonth = getStartDateOfMonth(days) + + // Determine the starting point. + const startDay = new Date(startDateOfMonth) + startDay.setDate(startDay.getDate() - startDateOfMonth.getDay()) + + const fullMonthDays: Date[] = [] + + // We'll be showing 42 days per month, because: 6 rows * 7 days = 42 days. + for (let i = 0; i < 42; i++) { + const currentDate = new Date(startDay) + currentDate.setDate(currentDate.getDate() + i) + fullMonthDays.push(currentDate) + } + + return fullMonthDays +} + +// Given an index in fullMonthDays, get the index of the previous and next day. +const getAdjacentDaysIndexes = (index: number, fullMonthDays: Date[]) => { + const prevIndex = index - 1 + const nextIndex = index + 1 + + return { + prevIndex: prevIndex >= 0 ? prevIndex : null, + nextIndex: nextIndex < fullMonthDays.length ? nextIndex : null, + } +} + +export const getDayBorders = ( + index: number, + fullMonthDays: Date[], + isSelected: boolean, + startDate: Date | null, + endDate: Date | null, +): string => { + if (!isSelected) return '' + + if (!startDate || !endDate) return calendarClasses.dayBorderLeftAndRight + + const { prevIndex, nextIndex } = getAdjacentDaysIndexes(index, fullMonthDays) + + const prevIsInDateRange = + prevIndex !== null && + isDateWithinRange(fullMonthDays[prevIndex], startDate, endDate) + const nextIsInDateRange = + nextIndex !== null && + isDateWithinRange(fullMonthDays[nextIndex], startDate, endDate) + + const prevIsSelected = + (prevIndex !== null && isSameDay(fullMonthDays[prevIndex], startDate)) || + prevIsInDateRange + const nextIsSelected = + (nextIndex !== null && isSameDay(fullMonthDays[nextIndex], startDate)) || + nextIsInDateRange + + // CSS classes for borders + let borderClasses = '' + if (isSelected) { + if (index % 7 === 0) { + // Leftmost day of the week + borderClasses = nextIsSelected + ? calendarClasses.dayBorderLeft + : calendarClasses.dayBorderLeftAndRight + } else if (index % 7 === 6) { + // Rightmost day of the week + borderClasses = prevIsSelected + ? calendarClasses.dayBorderRight + : calendarClasses.dayBorderLeftAndRight + } else { + // Middle days + if (prevIsSelected && nextIsSelected) { + borderClasses = '' // No left/right border if surrounded by selected days + } else if (prevIsSelected) { + borderClasses = calendarClasses.dayBorderRight + } else if (nextIsSelected) { + borderClasses = calendarClasses.dayBorderLeft + } else { + borderClasses = calendarClasses.dayBorderLeftAndRight + } + } + } + + return borderClasses +} + +export const switchCalendar = ( + currentCalendar: CalendarType, + newCalendar: CalendarType, +): CalendarType => { + if (!currentCalendar) { + return newCalendar + } + + // If the currentCalendar === newCalendar - that means we are closing the calendar. + if (currentCalendar === newCalendar) { + return null + } + + // If the currentCalendar !== newCalendar - that means we're switching the calendar type. + if (currentCalendar !== newCalendar) { + return newCalendar + } + + // Default case - return newCalendar. + return newCalendar +} + +export function isValidRange( + startDateString: string, + endDateString: string, +): boolean { + // Default - if no start or end date is provided, assume a valid range (even though there's no range) + if (!startDateString || !endDateString) return true + + // Convert string to Date objects after removing timezone offset + let startDate = new Date( + dateToISODateString(removeDateTimezoneOffset(new Date(startDateString))), + ) + + let endDate = new Date( + dateToISODateString(removeDateTimezoneOffset(new Date(endDateString))), + ) + + // Check if the end date is after the start date + return endDate > startDate +} diff --git a/packages/lsd-react/src/utils/dom.util.ts b/packages/lsd-react/src/utils/dom.util.ts new file mode 100644 index 0000000..e490a01 --- /dev/null +++ b/packages/lsd-react/src/utils/dom.util.ts @@ -0,0 +1,10 @@ +export const wasElementClicked = ( + event: Event, + element: HTMLElement | null, +): boolean => { + if (!element) { + return false + } + + return event?.composedPath().includes(element) || false +}