feat: implement date range picker

This commit is contained in:
jongomez 2023-10-09 11:32:22 +01:00 committed by Jon
parent a654db399a
commit db4d10d34d
21 changed files with 1172 additions and 290 deletions

View File

@ -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<ReturnType<typeof withTheme> | SerializedStyles> =
[
@ -86,6 +87,7 @@ const componentStyles: Array<ReturnType<typeof withTheme> | SerializedStyles> =
ToastStyles,
ToastProviderStyles,
ButtonGroupStyles,
DateRangePickerStyles,
]
export const CSSBaseline: React.FC<{ theme?: Theme }> = ({

View File

@ -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',
}

View File

@ -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<CalendarContextType>(

View File

@ -28,12 +28,6 @@ export const Uncontrolled: StoryObj<CalendarProps> = {
</div>
)
},
args: {
value: undefined,
onChange: undefined,
size: 'large',
},
}
export const Controlled: StoryObj<CalendarProps> = {
@ -48,10 +42,16 @@ export const Controlled: StoryObj<CalendarProps> = {
</div>
)
},
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',
}

View File

@ -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;
}
`

View File

@ -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<HTMLDivElement>,
'label' | 'onChange'
> & {
export type CalendarType = null | 'endDate' | 'startDate'
export type CalendarProps = CommonProps &
Omit<React.HTMLAttributes<HTMLDivElement>, 'label' | 'onChange'> & {
open?: boolean
disabled?: boolean
value?: string
onChange: (data: Date) => void
calendarType?: CalendarType
onStartDateChange?: (startDate: Date) => void
onEndDateChange?: (endDate: Date) => void
handleRef: React.RefObject<HTMLElement>
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<CalendarProps> & {
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<HTMLDivElement>(null)
const [style, setStyle] = useState<React.CSSProperties>({})
const [value, setValue] = useState<Date | null>(
valueProp
? safeConvertDateToString(valueProp, minDate, maxDate).date
const [startDate, setStartDate] = useState<Date | null>(
startDateProp
? safeConvertDate(startDateProp, minDate, maxDate).date
: null,
)
const isOpenControlled = typeof open !== 'undefined'
const [endDate, setEndDate] = useState<Date | null>(
endDateProp ? safeConvertDate(endDateProp, minDate, maxDate).date : null,
)
const [changeYearMode, setChangeYearMode] = useState(false)
useClickAway(ref, (event) => {
if (!open) return
onCalendarClickaway && onCalendarClickaway(event)
if (isOpenControlled) return
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<CalendarProps> & {
onDateFocus,
goToPreviousMonths,
goToNextMonths,
goToNextYear,
goToPreviousYear,
} = useDatepicker({
startDate: value ? new Date(value) : null,
endDate: null,
startDate,
endDate,
// focusedInput is meant to define which <input> 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<CalendarProps> & {
<CalendarContext.Provider
value={{
size,
mode,
startDate,
endDate,
focusedDate,
isDateFocused,
isDateSelected,
@ -130,11 +179,19 @@ export const Calendar: React.FC<CalendarProps> & {
onDateSelect,
onDateFocus,
onDateHover,
goToPreviousMonths,
goToNextMonths,
goToNextYear,
goToPreviousYear,
changeYearMode,
setChangeYearMode,
}}
>
<div
{...props}
className={clsx(
{ ...omitCommonProps(props) },
commonProps.className,
props.className,
calendarClasses.root,
open && calendarClasses.open,
@ -151,8 +208,6 @@ export const Calendar: React.FC<CalendarProps> & {
month={month.month}
firstDayOfWeek={0}
size={size}
goToPreviousMonths={goToPreviousMonths}
goToNextMonths={goToNextMonths}
/>
))}
</div>

View File

@ -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 (
<button
onClick={(e) => !disabled && onClick()}
onKeyDown={(e) => !disabled && onKeyDown(e)}
onMouseEnter={(e) => !disabled && onMouseEnter()}
tabIndex={tabIndex}
type="button"
<td
onClick={dayHandlers.onClick}
onMouseEnter={dayHandlers.onMouseEnter}
tabIndex={dayHandlers.tabIndex}
ref={dayRef}
className={clsx(
calendarClasses.day,
!disabled && isDateFocused(date) && calendarClasses.daySelected,
calendarClasses.dayContainer,
// The top and bottom borders are always shown for every selected day.
// That's not the case for left and right borders (e.g. 2 adjacent days will not have the middle border).
isSelected && calendarClasses.dayBorderTopAndBottom,
disabled && calendarClasses.dayDisabled,
isToday && calendarClasses.dayIsToday,
borderClasses,
)}
>
<div className={calendarClasses.day}>
<Typography variant="label2">{parseInt(day, 10)}</Typography>
{isToday && (
<Typography variant="label2" className={calendarClasses.todayIndicator}>
<Typography
variant="label2"
className={calendarClasses.todayIndicator}
>
</Typography>
)}
</button>
</div>
</td>
)
}

View File

@ -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<HTMLDivElement>(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 (
<>
<div className={calendarClasses.header}>
<button
className={clsx(calendarClasses.button)}
type="button"
onClick={goToPreviousMonths}
>
<ChevronLeftIcon color="primary" />
</button>
<div className={calendarClasses.row}>
<Typography
className={calendarClasses.month}
component="span"
variant={size === 'large' ? 'label1' : 'label2'}
>
{monthLabel.split(' ')[0]}
</Typography>
{changeYear ? (
<div ref={ref} className={calendarClasses.changeYear}>
<Typography
component="span"
className={calendarClasses.year}
variant={size === 'large' ? 'label1' : 'label2'}
>
{monthLabel.split(' ')[1]}
</Typography>
<div className={calendarClasses.row}>
<IconButton
onClick={() => setYear(year + 1)}
className={calendarClasses.changeYearButton}
>
<ChevronUpIcon color="primary" />
</IconButton>
<IconButton
onClick={() => setYear(year - 1)}
className={calendarClasses.changeYearButton}
>
<ChevronDownIcon color="primary" />
</IconButton>
</div>
</div>
) : (
<Typography
onClick={() => setChangeYear(true)}
variant={size === 'large' ? 'label1' : 'label2'}
className={calendarClasses.year}
>
{monthLabel.split(' ')[1]}
</Typography>
)}
</div>
<button
className={clsx(calendarClasses.button)}
type="button"
onClick={goToNextMonths}
>
<ChevronRightIcon color="primary" />
</button>
</div>
<div className={clsx(calendarClasses.grid)}>
{weekdayLabels.map((dayLabel, idx) => (
<Typography
key={idx}
variant="label2"
className={calendarClasses.weekDay}
>
{dayLabel[0]}
</Typography>
))}
</div>
<div className={clsx(calendarClasses.grid)}>
{days.length == 28 &&
new Array(7)
.fill(null)
.map((_, idx) => (
<Day
date={new Date()}
day={renderOtherDays(idx - 7, getDay(0).date).toString()}
key={`prev-${idx}`}
disabled={true}
/>
))}
{days.map((ele, idx) =>
typeof ele !== 'number' ? (
<Day date={ele.date} day={ele.dayLabel} key={ele.dayLabel} />
) : (
<Day
date={getDay(idx + days.lastIndexOf(0) + 1).date}
day={renderOtherDays(
idx - days.filter((day) => 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) => (
<Day
date={getDay(idx + days.lastIndexOf(0) + 1).date}
day={renderOtherDays(
idx,
getDay(days.lastIndexOf(0) + 1).date,
).toString()}
key={`after-${idx}`}
disabled={true}
/>
))}
</div>
<MonthHeader monthLabel={monthLabel} size={size} />
<table className={calendarClasses.monthTable}>
<thead>
<WeekdayHeader weekdayLabels={weekdayLabels} />
</thead>
<tbody>
<Days days={days} />
</tbody>
</table>
</>
)
}

View File

@ -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<CalendarNavigationButtonProps> = ({
direction,
onClick,
}) => {
const Icon = direction === 'previous' ? NavigateBeforeIcon : NavigateNextIcon
return (
<button
className={clsx(calendarClasses.button)}
type="button"
onClick={onClick}
>
<Icon color="primary" />
</button>
)
}
type YearControlProps = {
year: string
size: 'large' | 'medium' | 'small'
onClickAway: () => void
}
export const YearControl: FC<YearControlProps> = ({
year,
size,
onClickAway,
}) => {
const ref = useRef<HTMLDivElement>(null)
const { goToNextYear, goToPreviousYear } = useCalendarContext()
useClickAway(ref, () => {
onClickAway()
})
return (
<div ref={ref} className={calendarClasses.changeYear}>
<Typography
component="span"
className={calendarClasses.year}
variant={size === 'large' ? 'label1' : 'label2'}
>
{year}
</Typography>
<div className={calendarClasses.row}>
<IconButton
onClick={() => {
goToNextYear()
}}
className={calendarClasses.changeYearButton}
>
<ArrowUpIcon color="primary" />
</IconButton>
<IconButton
onClick={() => {
goToPreviousYear()
}}
className={calendarClasses.changeYearButton}
>
<ArrowDownIcon color="primary" />
</IconButton>
</div>
</div>
)
}
type MonthHeaderProps = {
monthLabel: string
size: 'large' | 'medium' | 'small'
}
export const MonthHeader: FC<MonthHeaderProps> = ({ monthLabel, size }) => {
const {
goToPreviousMonths,
goToNextMonths,
changeYearMode,
setChangeYearMode,
} = useCalendarContext()
const [month, year] = monthLabel.split(' ')
return (
<div className={calendarClasses.header}>
<CalendarNavigationButton
direction="previous"
onClick={goToPreviousMonths}
/>
<div className={calendarClasses.row}>
<Typography
className={calendarClasses.month}
component="span"
variant={size === 'large' ? 'label1' : 'label2'}
>
{month}
</Typography>
{changeYearMode ? (
<YearControl
year={year}
size={size}
onClickAway={() => {
setChangeYearMode(false)
}}
/>
) : (
<Typography
onClick={() => setChangeYearMode(true)}
variant={size === 'large' ? 'label1' : 'label2'}
className={calendarClasses.year}
>
{year}
</Typography>
)}
</div>
<CalendarNavigationButton direction="next" onClick={goToNextMonths} />
</div>
)
}
type WeekdayHeaderProps = {
weekdayLabels: string[]
}
export const WeekdayHeader: FC<WeekdayHeaderProps> = ({ weekdayLabels }) => {
return (
<tr>
{weekdayLabels.map((dayLabel, idx) => (
<th key={idx}>
<div className={calendarClasses.weekDay}>
<Typography variant="label2">{dayLabel[0]}</Typography>
</div>
</th>
))}
</tr>
)
}
type DaysProps = {
days: UseMonthResult['days']
}
export const Days: FC<DaysProps> = ({ 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) => (
<tr key={`week-${weekIdx}`}>
{Array.from({ length: 7 }).map((_, dayIdx) => {
const index = weekIdx * 7 + dayIdx
const date = fullMonthDays[index]
return (
<Day
key={`day-${index}`}
index={index}
day={date.getDate().toString()}
fullMonthDays={fullMonthDays}
disabled={date.getMonth() !== currentMonth}
/>
)
})}
</tr>
))}
</>
)
}

View File

@ -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',

View File

@ -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;

View File

@ -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<HTMLDivElement>,
'onChange' | 'value'
> &
export type DateFieldProps = CommonProps &
Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange' | 'value'> &
Pick<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> & {
label?: React.ReactNode
size?: 'large' | 'medium' | 'small'
@ -45,9 +48,11 @@ export const DateField: React.FC<DateFieldProps> & {
icon,
onIconClick,
inputProps = {},
calendarIconRef,
variant = 'underlined',
...props
}) => {
const commonProps = useCommonProps(props)
const ref = useRef<HTMLInputElement>(null)
const input = useInput({
defaultValue,
@ -65,7 +70,9 @@ export const DateField: React.FC<DateFieldProps> & {
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<DateFieldProps> & {
<span
className={dateFieldClasses.icon}
onClick={() => !disabled && onIconClick && onIconClick()}
ref={props.calendarIconRef}
ref={calendarIconRef}
>
{icon}
</span>
@ -124,7 +131,10 @@ export const DateField: React.FC<DateFieldProps> & {
>
<CloseIcon color="primary" />
</span>
) : null}
) : (
// Default case: just show and empty span on top of the browser's default icon.
<span className={dateFieldClasses.noIcon} />
)}
</div>
{supportingText && (
<div className={clsx(dateFieldClasses.supportingText)}>

View File

@ -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<HTMLDivElement>,
'onChange' | 'value'
> &
import {
CommonProps,
omitCommonProps,
pickCommonProps,
useCommonProps,
} from '../../utils/useCommonProps'
export type DatePickerProps = CommonProps &
Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange' | 'value'> &
Pick<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> & {
label?: React.ReactNode
error?: boolean
@ -42,6 +48,7 @@ export const DatePicker: React.FC<DatePickerProps> & {
variant = 'underlined',
...props
}) => {
const commonProps = useCommonProps(props)
const ref = useRef<HTMLDivElement>(null)
const calendarIconRef = useRef<HTMLSpanElement>(null)
const [openCalendar, setOpenCalendar] = useState(false)
@ -68,7 +75,9 @@ export const DatePicker: React.FC<DatePickerProps> & {
ref={ref}
{...props}
className={clsx(
{ ...omitCommonProps(props) },
props.className,
commonProps.className,
datePickerClasses.root,
datePickerClasses[size],
)}
@ -88,22 +97,20 @@ export const DatePicker: React.FC<DatePickerProps> & {
<Portal id="calendar">
{withCalendar && (
<Calendar
onChange={(date) => 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}
/>

View File

@ -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',
}

View File

@ -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<DateRangePickerProps> = ({ ...args }) => {
return <DateRangePicker {...args} />
}
export const Controlled: Story<DateRangePickerProps> = ({ ...args }) => {
return <DateRangePicker {...args} />
}
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',
}

View File

@ -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));
}
}
`

View File

@ -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<DatePickerProps, 'value' | 'clearButton'> & {
startValue?: string
endValue?: string
}
export const DateRangePicker: React.FC<DateRangePickerProps> & {
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<HTMLDivElement>(null)
const endCalendarIconRef = useRef<HTMLSpanElement>(null)
const startCalendarIconRef = useRef<HTMLSpanElement>(null)
const [calendarType, setCalendarType] = useState<CalendarType>(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<HTMLInputElement>) => {
if (!endInput.value || isValidRange(e.target.value, endInput.value)) {
startInput.onChange(e)
}
}
const onEndInputChange = (e: ChangeEvent<HTMLInputElement>) => {
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 (
<div
ref={ref}
className={clsx(
{ ...omitCommonProps(props) },
commonProps.className,
props.className,
dateRangePickerClasses.root,
dateRangePickerClasses[size],
withCalendar && dateRangePickerClasses.withCalendar,
isCalendarOpen && dateRangePickerClasses.openCalendar,
disabled && dateRangePickerClasses.disabled,
)}
>
{label && (
<Typography
className={dateRangePickerClasses.label}
variant="label2"
component="label"
>
{label}
</Typography>
)}
<div
className={clsx(
props.className,
dateRangePickerClasses.inputContainer,
variant === 'outlined' && dateRangePickerClasses.outlined,
)}
>
<DateField
variant={variant}
calendarIconRef={startCalendarIconRef}
icon={withCalendar && <CalendarIcon color="primary" />}
// 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}
/>
<div className={dateRangePickerClasses.separator} />
<DateField
variant={variant}
calendarIconRef={endCalendarIconRef}
icon={withCalendar && <CalendarIcon color="primary" />}
// 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}
/>
</div>
{supportingText && (
<div className={clsx(dateRangePickerClasses.supportingText)}>
<Typography variant={'label2'} component="p">
{supportingText}
</Typography>
</div>
)}
{withCalendar && (
<Portal id="calendar">
<Calendar
{...pickCommonProps(props)}
onStartDateChange={calendarStartDateChange}
onEndDateChange={calendarEndDateChange}
onCalendarClickaway={(event) => {
// 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}
/>
</Portal>
)}
</div>
)
}
DateRangePicker.classes = dateRangePickerClasses

View File

@ -0,0 +1 @@
export * from './DateRangePicker'

View File

@ -40,3 +40,4 @@ export * from './components/Calendar'
export * from './components/Toast'
export * from './components/ToastProvider'
export * from './components/ButtonGroup'
export * from './components/DateRangePicker'

View File

@ -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
}

View File

@ -0,0 +1,10 @@
export const wasElementClicked = (
event: Event,
element: HTMLElement | null,
): boolean => {
if (!element) {
return false
}
return event?.composedPath().includes(element) || false
}