mirror of https://github.com/acid-info/lsd.git
feat: implement date range picker
This commit is contained in:
parent
a654db399a
commit
db4d10d34d
|
@ -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 }> = ({
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
|
|
|
@ -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>(
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
`
|
||||
|
|
|
@ -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'
|
||||
> & {
|
||||
open?: boolean
|
||||
disabled?: boolean
|
||||
value?: string
|
||||
onChange: (data: Date) => void
|
||||
handleRef: React.RefObject<HTMLElement>
|
||||
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<React.HTMLAttributes<HTMLDivElement>, 'label' | 'onChange'> & {
|
||||
open?: boolean
|
||||
disabled?: boolean
|
||||
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
|
||||
|
||||
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<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>
|
||||
|
|
|
@ -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,
|
||||
)}
|
||||
>
|
||||
<Typography variant="label2">{parseInt(day, 10)}</Typography>
|
||||
{isToday && (
|
||||
<Typography variant="label2" className={calendarClasses.todayIndicator}>
|
||||
▬
|
||||
</Typography>
|
||||
)}
|
||||
</button>
|
||||
<div className={calendarClasses.day}>
|
||||
<Typography variant="label2">{parseInt(day, 10)}</Typography>
|
||||
{isToday && (
|
||||
<Typography
|
||||
variant="label2"
|
||||
className={calendarClasses.todayIndicator}
|
||||
>
|
||||
▬
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -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',
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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)}>
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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',
|
||||
}
|
|
@ -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',
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
`
|
|
@ -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
|
|
@ -0,0 +1 @@
|
|||
export * from './DateRangePicker'
|
|
@ -40,3 +40,4 @@ export * from './components/Calendar'
|
|||
export * from './components/Toast'
|
||||
export * from './components/ToastProvider'
|
||||
export * from './components/ButtonGroup'
|
||||
export * from './components/DateRangePicker'
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
export const wasElementClicked = (
|
||||
event: Event,
|
||||
element: HTMLElement | null,
|
||||
): boolean => {
|
||||
if (!element) {
|
||||
return false
|
||||
}
|
||||
|
||||
return event?.composedPath().includes(element) || false
|
||||
}
|
Loading…
Reference in New Issue