From 1200b339a749db7cc2180d21a940b20ebb68b304 Mon Sep 17 00:00:00 2001 From: jongomez Date: Wed, 18 Oct 2023 00:07:43 +0100 Subject: [PATCH] fix: better year select dropdown and other minor updates --- .../components/Calendar/Calendar.classes.ts | 1 + .../components/Calendar/Calendar.context.ts | 9 +- .../components/Calendar/Calendar.styles.ts | 25 ++-- .../src/components/Calendar/Calendar.tsx | 25 +--- .../lsd-react/src/components/Calendar/Day.tsx | 12 +- .../src/components/Calendar/MonthHelpers.tsx | 88 +----------- .../src/components/Calendar/YearControl.tsx | 135 ++++++++++++++++++ .../src/components/DatePicker/DatePicker.tsx | 7 +- .../DateRangePicker.stories.tsx | 4 +- .../DateRangePicker/DateRangePicker.tsx | 9 +- packages/lsd-react/src/utils/date.utils.ts | 34 ++--- 11 files changed, 188 insertions(+), 161 deletions(-) create mode 100644 packages/lsd-react/src/components/Calendar/YearControl.tsx diff --git a/packages/lsd-react/src/components/Calendar/Calendar.classes.ts b/packages/lsd-react/src/components/Calendar/Calendar.classes.ts index ec0bf99..be87cb0 100644 --- a/packages/lsd-react/src/components/Calendar/Calendar.classes.ts +++ b/packages/lsd-react/src/components/Calendar/Calendar.classes.ts @@ -39,4 +39,5 @@ export const calendarClasses = { previousMonthButton: 'lsd-calendar__previous-month-button', yearDropdown: 'lsd-calendar__year-dropdown', + yearDropdownHidden: 'lsd-calendar__year-dropdown--hidden', } diff --git a/packages/lsd-react/src/components/Calendar/Calendar.context.ts b/packages/lsd-react/src/components/Calendar/Calendar.context.ts index 8b62010..910ba81 100644 --- a/packages/lsd-react/src/components/Calendar/Calendar.context.ts +++ b/packages/lsd-react/src/components/Calendar/Calendar.context.ts @@ -1,21 +1,14 @@ 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 - isDateBlocked: (date: Date) => boolean - isFirstOrLastSelectedDate: (date: Date) => boolean onDateFocus: (date: Date) => void - onDateHover: (date: Date) => void - onDateSelect: (date: Date) => void goToPreviousMonths: () => void goToNextMonths: () => void + onDateSelect: (date: Date) => void changeYearMode: boolean setChangeYearMode: (value: boolean) => void goToDate: (date: Date) => void diff --git a/packages/lsd-react/src/components/Calendar/Calendar.styles.ts b/packages/lsd-react/src/components/Calendar/Calendar.styles.ts index 933bce6..4c15ec6 100644 --- a/packages/lsd-react/src/components/Calendar/Calendar.styles.ts +++ b/packages/lsd-react/src/components/Calendar/Calendar.styles.ts @@ -58,17 +58,9 @@ export const CalendarStyles = css` } .${calendarClasses.changeYearActive} { - .${calendarClasses.year} { - padding: 5px 0px 5px 10px; - } - .${calendarClasses.yearAndIcon} { border: 1px solid rgb(var(--lsd-border-primary)); } - - .${calendarClasses.changeYearIconContainer} { - padding-right: 5px; - } } .${calendarClasses.changeYearIconContainer} { @@ -78,7 +70,7 @@ export const CalendarStyles = css` cursor: pointer; border: none; width: 14px; - padding-left: 5px; + padding: 0 6px; } .${calendarClasses.month} { @@ -209,6 +201,8 @@ export const CalendarStyles = css` width: 100%; border: 1px solid rgb(var(--lsd-border-primary)); + border-top: none; + z-index: 1; .${calendarClasses.year} { @@ -216,6 +210,10 @@ export const CalendarStyles = css` } } + .${calendarClasses.yearDropdownHidden} { + visibility: hidden; + } + .${calendarClasses.year} { display: flex; cursor: pointer; @@ -224,17 +222,14 @@ export const CalendarStyles = css` background: rgb(var(--lsd-surface-primary)); + padding: 5px 0px 5px 12px; + :hover { text-decoration: underline; - padding: 5px 0px 5px 10px; } } - .${calendarClasses.yearAndIcon}:hover { + .${calendarClasses.yearAndIcon} { border: 1px solid rgb(var(--lsd-border-primary)); - - .${calendarClasses.changeYearIconContainer} { - padding-right: 5px; - } } ` diff --git a/packages/lsd-react/src/components/Calendar/Calendar.tsx b/packages/lsd-react/src/components/Calendar/Calendar.tsx index 8502e13..3f009bc 100644 --- a/packages/lsd-react/src/components/Calendar/Calendar.tsx +++ b/packages/lsd-react/src/components/Calendar/Calendar.tsx @@ -17,6 +17,9 @@ import { } from '../../utils/useCommonProps' import { TooltipBase } from '../TooltipBase' +export const CALENDAR_MIN_YEAR = 1850 +export const CALENDAR_MAX_YEAR = 2100 + export type CalendarType = null | 'endDate' | 'startDate' export type CalendarProps = CommonProps & @@ -54,8 +57,8 @@ export const Calendar: React.FC & { endDate: endDateProp, calendarType = 'startDate', // minDate and maxDate are necessary because onDateFocus freaks out with small/large date values. - minDate = new Date(1950, 0, 1), - maxDate = new Date(2100, 0, 1), + minDate = new Date(CALENDAR_MIN_YEAR, 0, 1), + maxDate = new Date(CALENDAR_MAX_YEAR, 0, 1), tooltipArrowOffset, ...props }) => { @@ -98,15 +101,8 @@ export const Calendar: React.FC & { const { activeMonths, - isDateSelected, - isDateHovered, - isFirstOrLastSelectedDate, - isDateBlocked, - isDateFocused, - focusedDate, - onDateHover, - onDateSelect, onDateFocus, + onDateSelect, goToPreviousMonths, goToNextMonths, goToDate, @@ -172,15 +168,8 @@ export const Calendar: React.FC & { mode, startDate, endDate, - focusedDate, - isDateFocused, - isDateSelected, - isDateHovered, - isDateBlocked, - isFirstOrLastSelectedDate, - onDateSelect, onDateFocus, - onDateHover, + onDateSelect, goToPreviousMonths, goToNextMonths, goToDate, diff --git a/packages/lsd-react/src/components/Calendar/Day.tsx b/packages/lsd-react/src/components/Calendar/Day.tsx index 8acb91a..7e968ce 100644 --- a/packages/lsd-react/src/components/Calendar/Day.tsx +++ b/packages/lsd-react/src/components/Calendar/Day.tsx @@ -1,5 +1,4 @@ -import { useRef } from 'react' -import { useDay } from '@datepicker-react/hooks' +import { useCallback, useRef } from 'react' import { useCalendarContext } from './Calendar.context' import clsx from 'clsx' import { Typography } from '../Typography' @@ -25,13 +24,14 @@ export const Day = ({ disabled = false, }: DayProps) => { const date = fullMonthDays[index] - const { mode, startDate, endDate, ...calendarContext } = useCalendarContext() + const { mode, startDate, endDate, onDateSelect } = useCalendarContext() const dayRef = useRef(null) - const dayHandlers = useDay({ date, dayRef, ...calendarContext }) const isToday = resetHours(date) === resetHours(new Date()) const isInDateRange = mode === 'range' && isDateWithinRange(date, startDate, endDate) + const onClick = useCallback(() => onDateSelect(date), [date, onDateSelect]) + const isStartDate = isSameDay(date, startDate) const isEndDate = mode === 'range' && isSameDay(date, endDate) const isSelected = isStartDate || isEndDate || isInDateRange @@ -50,9 +50,7 @@ export const Day = ({ return ( = ({ ) } -type YearControlProps = { - year: string - monthNumber: number - size: 'large' | 'medium' | 'small' -} - -export const YearControl: FC = ({ - year, - monthNumber, - size, -}) => { - const ref = useRef(null) - const { goToDate, changeYearMode, setChangeYearMode } = useCalendarContext() - - useClickAway(ref, () => { - setChangeYearMode(false) - }) - - const handleYearClick = (selectedYear: number) => { - const selectedDate = new Date(selectedYear, monthNumber, 1) - goToDate(selectedDate) - setChangeYearMode(false) - } - - const yearsList = Array.from({ length: 101 }, (_, i) => 1950 + i) - - return ( -
{ - setChangeYearMode(!changeYearMode) - }} - > -
- - {year} - - -
- {changeYearMode ? ( - - ) : ( - - )} -
-
- - {changeYearMode && ( -
- {yearsList.map((year) => ( -
handleYearClick(year)} - > - - {year} - -
- ))} -
- )} -
- ) -} - type MonthHeaderProps = { monthLabel: string monthNumber: number diff --git a/packages/lsd-react/src/components/Calendar/YearControl.tsx b/packages/lsd-react/src/components/Calendar/YearControl.tsx new file mode 100644 index 0000000..5820f90 --- /dev/null +++ b/packages/lsd-react/src/components/Calendar/YearControl.tsx @@ -0,0 +1,135 @@ +import clsx from 'clsx' +import { FC, useEffect, useRef, useState } from 'react' +import { ArrowDownIcon, ArrowUpIcon } from '../Icons' +import { calendarClasses } from './Calendar.classes' +import { Typography } from '../Typography' +import { useClickAway, useScroll } from 'react-use' +import { useCalendarContext } from './Calendar.context' +import { CALENDAR_MAX_YEAR, CALENDAR_MIN_YEAR } from '.' + +type YearControlProps = { + year: string + monthNumber: number + size: 'large' | 'medium' | 'small' + yearStep?: number +} + +export const YearControl: FC = ({ + year, + monthNumber, + size, + yearStep = 10, +}) => { + const ref = useRef(null) + const currentYearRef = useRef(null) + const { goToDate, changeYearMode, setChangeYearMode } = useCalendarContext() + const scrollRef = useRef(null) + const { y } = useScroll(scrollRef) + + const [minYear, setMinYear] = useState(() => parseInt(year) - yearStep) + const [maxYear, setMaxYear] = useState(() => parseInt(year) + yearStep) + + const yearsList = Array.from( + { length: maxYear - minYear + 1 }, + (_, i) => minYear + i, + ) + + useClickAway(ref, () => { + setChangeYearMode(false) + }) + + const handleYearClick = (selectedYear: number) => { + const selectedDate = new Date(selectedYear, monthNumber, 1) + goToDate(selectedDate) + setChangeYearMode(false) + } + + // The following useEffect scrolls to the current year when the year dropdown is opened. + useEffect(() => { + if (changeYearMode && currentYearRef.current && scrollRef.current) { + const yearElementTop = currentYearRef.current.offsetTop + const yearElementHeight = currentYearRef.current.offsetHeight + const containerHeight = scrollRef.current.clientHeight + + const scrollToPosition = + yearElementTop - containerHeight / 2 + yearElementHeight / 2 + + scrollRef.current.scrollTop = scrollToPosition + } + }, [changeYearMode]) + + useEffect(() => { + const scrollHeight = scrollRef?.current?.scrollHeight + const clientHeight = scrollRef?.current?.clientHeight + + if (!scrollHeight || !clientHeight) return + + const scrollPercentage = (y / (scrollHeight - clientHeight)) * 100 + + if (scrollPercentage > 90) { + setMaxYear((prevMaxYear) => + Math.min(prevMaxYear + yearStep, CALENDAR_MAX_YEAR), + ) + } + + if (scrollPercentage < 10) { + setMinYear((prevMinYear) => + Math.max(prevMinYear - yearStep, CALENDAR_MIN_YEAR), + ) + } + }, [y, yearStep]) + + return ( +
{ + setChangeYearMode(!changeYearMode) + }} + > +
+ + {year} + + +
+ {changeYearMode ? ( + + ) : ( + + )} +
+
+ +
+ {yearsList.map((yearValue) => ( +
handleYearClick(yearValue)} + ref={yearValue === parseInt(year) ? currentYearRef : null} + > + + {yearValue} + +
+ ))} +
+
+ ) +} diff --git a/packages/lsd-react/src/components/DatePicker/DatePicker.tsx b/packages/lsd-react/src/components/DatePicker/DatePicker.tsx index b2ee972..8e7d617 100644 --- a/packages/lsd-react/src/components/DatePicker/DatePicker.tsx +++ b/packages/lsd-react/src/components/DatePicker/DatePicker.tsx @@ -1,9 +1,6 @@ import clsx from 'clsx' import React, { useRef, useState } from 'react' -import { - dateToISODateString, - removeDateTimezoneOffset, -} from '../../utils/date.utils' +import { adjustedTimezoneISOString } from '../../utils/date.utils' import { useInput } from '../../utils/useInput' import { Calendar } from '../Calendar' import { DateField } from '../DateField' @@ -65,7 +62,7 @@ export const DatePicker: React.FC & { }) const handleDateChange = (date: Date) => - input.setValue(dateToISODateString(removeDateTimezoneOffset(date))) + input.setValue(adjustedTimezoneISOString(date)) const inputId = (props.id || 'date-picker') + '-input' diff --git a/packages/lsd-react/src/components/DateRangePicker/DateRangePicker.stories.tsx b/packages/lsd-react/src/components/DateRangePicker/DateRangePicker.stories.tsx index c8d1fda..0f4ec00 100644 --- a/packages/lsd-react/src/components/DateRangePicker/DateRangePicker.stories.tsx +++ b/packages/lsd-react/src/components/DateRangePicker/DateRangePicker.stories.tsx @@ -38,7 +38,7 @@ Uncontrolled.args = { errorIcon: false, withCalendar: true, size: 'large', - variant: 'outlined-bottom', + variant: 'underlined', } Controlled.args = { @@ -51,5 +51,5 @@ Controlled.args = { errorIcon: false, withCalendar: true, size: 'large', - variant: 'outlined-bottom', + variant: 'underlined', } diff --git a/packages/lsd-react/src/components/DateRangePicker/DateRangePicker.tsx b/packages/lsd-react/src/components/DateRangePicker/DateRangePicker.tsx index 974e63c..9ad464d 100644 --- a/packages/lsd-react/src/components/DateRangePicker/DateRangePicker.tsx +++ b/packages/lsd-react/src/components/DateRangePicker/DateRangePicker.tsx @@ -1,10 +1,9 @@ import clsx from 'clsx' import React, { ChangeEvent, ChangeEventHandler, useRef, useState } from 'react' import { - dateToISODateString, + adjustedTimezoneISOString, getCalendarTooltipArrowOffset, isValidRange, - removeDateTimezoneOffset, switchCalendar, } from '../../utils/date.utils' import { useInput } from '../../utils/useInput' @@ -39,7 +38,7 @@ export const DateRangePicker: React.FC & { onStartDateChange, onEndDateChange, size = 'large', - variant = 'outlined-bottom', + variant = 'underlined', withCalendar = true, label, supportingText, @@ -87,14 +86,14 @@ export const DateRangePicker: React.FC & { } const calendarStartDateChange = (date: Date) => { - startInput.setValue(dateToISODateString(removeDateTimezoneOffset(date))) + startInput.setValue(adjustedTimezoneISOString(date)) // Switch to endDate calendar when the startDate is set. setCalendarType('endDate') } const calendarEndDateChange = (date: Date) => - endInput.setValue(dateToISODateString(removeDateTimezoneOffset(date))) + endInput.setValue(adjustedTimezoneISOString(date)) const dateFieldProps: DateFieldProps = { ...props, diff --git a/packages/lsd-react/src/utils/date.utils.ts b/packages/lsd-react/src/utils/date.utils.ts index 6d8d97f..9cf44d1 100644 --- a/packages/lsd-react/src/utils/date.utils.ts +++ b/packages/lsd-react/src/utils/date.utils.ts @@ -14,7 +14,10 @@ export const safeConvertDate = ( maxDate: Date, ): SafeConvertDateResult => { if (!value) return { isValid: false, date: null } - const date = new Date(value ?? undefined) + // If we call new Date() without 'T00:00:00', the resulting Date object may be incorrect. + // For example, in my timezone (GMT+1) if I do new Date('1900-01-01') + // I'll get back 'Dec 31 1899 23:23:15'. But, doing new Date('1900-01-01T00:00:00') works. + const date = new Date(value + 'T00:00:00') const isValid = !Number.isNaN(+date) && date >= minDate && date <= maxDate return { @@ -22,11 +25,11 @@ export const safeConvertDate = ( date, } } -export const removeDateTimezoneOffset = (date: Date) => - new Date(+date - date.getTimezoneOffset() * 60 * 1000) -export const dateToISODateString = (date: Date) => - date.toISOString().split('T')[0] +export const adjustedTimezoneISOString = (date: Date): string => { + const localDate = new Date(date.getTime() - date.getTimezoneOffset() * 60000) + return localDate.toISOString().split('T')[0] +} export const resetHours = (date: Date) => date.setHours(0, 0, 0, 0) @@ -43,12 +46,15 @@ export const isDateWithinRange = ( } export const isSameDay = ( - date: Date | null | undefined, - start: Date | null | undefined, + firstDate: Date | null | undefined, + secondDate: Date | null | undefined, ): boolean => { - if (!date || !start) return false + if (!firstDate || !secondDate) return false + + const isoStringFirstDate = adjustedTimezoneISOString(firstDate) + const isoStringSecondDate = adjustedTimezoneISOString(secondDate) - return resetHours(date) === resetHours(start) + return isoStringFirstDate === isoStringSecondDate } type NewDates = { @@ -221,13 +227,9 @@ export function isValidRange( if (!startDateString || !endDateString) return true // Convert string to Date objects after removing timezone offset - let startDate = new Date( - dateToISODateString(removeDateTimezoneOffset(new Date(startDateString))), - ) + let startDate = new Date(adjustedTimezoneISOString(new Date(startDateString))) - let endDate = new Date( - dateToISODateString(removeDateTimezoneOffset(new Date(endDateString))), - ) + let endDate = new Date(adjustedTimezoneISOString(new Date(endDateString))) // Check if the end date is after the start date return endDate > startDate @@ -241,7 +243,7 @@ export const getCalendarTooltipArrowOffset = ( if (calendarType === 'startDate') { return 130 } else { - return 290 + return 291 } }