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 { ToastStyles } from '../Toast/Toast.styles'
|
||||||
import { ToastProviderStyles } from '../ToastProvider/ToastProvider.styles'
|
import { ToastProviderStyles } from '../ToastProvider/ToastProvider.styles'
|
||||||
import { ButtonGroupStyles } from '../ButtonGroup/ButtonGroup.styles'
|
import { ButtonGroupStyles } from '../ButtonGroup/ButtonGroup.styles'
|
||||||
|
import { DateRangePickerStyles } from '../DateRangePicker/DateRangePicker.styles'
|
||||||
|
|
||||||
const componentStyles: Array<ReturnType<typeof withTheme> | SerializedStyles> =
|
const componentStyles: Array<ReturnType<typeof withTheme> | SerializedStyles> =
|
||||||
[
|
[
|
||||||
|
@ -86,6 +87,7 @@ const componentStyles: Array<ReturnType<typeof withTheme> | SerializedStyles> =
|
||||||
ToastStyles,
|
ToastStyles,
|
||||||
ToastProviderStyles,
|
ToastProviderStyles,
|
||||||
ButtonGroupStyles,
|
ButtonGroupStyles,
|
||||||
|
DateRangePickerStyles,
|
||||||
]
|
]
|
||||||
|
|
||||||
export const CSSBaseline: React.FC<{ theme?: Theme }> = ({
|
export const CSSBaseline: React.FC<{ theme?: Theme }> = ({
|
||||||
|
|
|
@ -6,7 +6,6 @@ export const calendarClasses = {
|
||||||
disabled: 'lsd-calendar--disabled',
|
disabled: 'lsd-calendar--disabled',
|
||||||
|
|
||||||
header: 'lsd-calendar-header',
|
header: 'lsd-calendar-header',
|
||||||
grid: 'lsd-calendar-body',
|
|
||||||
weekDay: 'lsd-calendar__week_day',
|
weekDay: 'lsd-calendar__week_day',
|
||||||
button: 'lsd-calendar__button',
|
button: 'lsd-calendar__button',
|
||||||
row: 'lsd-calendar__row',
|
row: 'lsd-calendar__row',
|
||||||
|
@ -16,9 +15,19 @@ export const calendarClasses = {
|
||||||
year: 'lsd-calendar-year',
|
year: 'lsd-calendar-year',
|
||||||
month: 'lsd-calendar-month',
|
month: 'lsd-calendar-month',
|
||||||
day: 'lsd-calendar-day',
|
day: 'lsd-calendar-day',
|
||||||
|
|
||||||
|
dayContainer: 'lsd-calendar-day__container',
|
||||||
|
dayRange: 'lsd-calendar-day--range',
|
||||||
daySelected: 'lsd-calendar-day--selected',
|
daySelected: 'lsd-calendar-day--selected',
|
||||||
dayDisabled: 'lsd-calendar-day--disabled',
|
dayDisabled: 'lsd-calendar-day--disabled',
|
||||||
dayIsToday: 'lsd-calendar-day--today',
|
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',
|
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 = {
|
export type CalendarContextType = {
|
||||||
focusedDate: Date | null
|
focusedDate: Date | null
|
||||||
size?: 'large' | 'medium' | 'small'
|
size?: 'large' | 'medium' | 'small'
|
||||||
|
mode?: 'date' | 'range'
|
||||||
|
startDate: Date | null
|
||||||
|
endDate: Date | null
|
||||||
isDateFocused: (date: Date) => boolean
|
isDateFocused: (date: Date) => boolean
|
||||||
isDateSelected: (date: Date) => boolean
|
isDateSelected: (date: Date) => boolean
|
||||||
isDateHovered: (date: Date) => boolean
|
isDateHovered: (date: Date) => boolean
|
||||||
|
@ -11,6 +14,12 @@ export type CalendarContextType = {
|
||||||
onDateFocus: (date: Date) => void
|
onDateFocus: (date: Date) => void
|
||||||
onDateHover: (date: Date) => void
|
onDateHover: (date: Date) => void
|
||||||
onDateSelect: (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>(
|
export const CalendarContext = React.createContext<CalendarContextType>(
|
||||||
|
|
|
@ -28,12 +28,6 @@ export const Uncontrolled: StoryObj<CalendarProps> = {
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
||||||
args: {
|
|
||||||
value: undefined,
|
|
||||||
onChange: undefined,
|
|
||||||
size: 'large',
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Controlled: StoryObj<CalendarProps> = {
|
export const Controlled: StoryObj<CalendarProps> = {
|
||||||
|
@ -48,10 +42,16 @@ export const Controlled: StoryObj<CalendarProps> = {
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
}
|
||||||
args: {
|
|
||||||
value: '2023-01-01',
|
Uncontrolled.args = {
|
||||||
onChange: undefined,
|
startDate: undefined,
|
||||||
size: 'large',
|
onStartDateChange: undefined,
|
||||||
},
|
size: 'large',
|
||||||
|
}
|
||||||
|
|
||||||
|
Controlled.args = {
|
||||||
|
startDate: '2023-01-01',
|
||||||
|
onStartDateChange: undefined,
|
||||||
|
size: 'large',
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,9 +17,9 @@ export const CalendarStyles = css`
|
||||||
}
|
}
|
||||||
|
|
||||||
.${calendarClasses.container} {
|
.${calendarClasses.container} {
|
||||||
display: grid;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
margin: 8px 2px 2px;
|
margin: 8px 2px 2px;
|
||||||
grid-gap: 0 64px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.${calendarClasses.open} {
|
.${calendarClasses.open} {
|
||||||
|
@ -35,19 +35,11 @@ export const CalendarStyles = css`
|
||||||
padding-bottom: 10px;
|
padding-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.${calendarClasses.grid} {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(7, 1fr);
|
|
||||||
justify-content: center;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.${calendarClasses.weekDay} {
|
.${calendarClasses.weekDay} {
|
||||||
text-align: center;
|
|
||||||
aspect-ratio: 1 / 1;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.${calendarClasses.row} {
|
.${calendarClasses.row} {
|
||||||
|
@ -86,12 +78,22 @@ export const CalendarStyles = css`
|
||||||
text-decoration-color: rgb(var(--lsd-border-primary));
|
text-decoration-color: rgb(var(--lsd-border-primary));
|
||||||
}
|
}
|
||||||
|
|
||||||
.${calendarClasses.day} {
|
.${calendarClasses.dayContainer} {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border: none;
|
|
||||||
background: transparent;
|
background: transparent;
|
||||||
aspect-ratio: 1 / 1;
|
|
||||||
position: relative;
|
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 {
|
.${calendarClasses.day}:hover {
|
||||||
|
@ -144,4 +146,29 @@ export const CalendarStyles = css`
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: 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 clsx from 'clsx'
|
||||||
import React, { useEffect, useRef, useState } from 'react'
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
import { useClickAway } from 'react-use'
|
import { useClickAway } from 'react-use'
|
||||||
import { safeConvertDateToString } from '../../utils/date.utils'
|
|
||||||
import { calendarClasses } from './Calendar.classes'
|
import { calendarClasses } from './Calendar.classes'
|
||||||
import { CalendarContext } from './Calendar.context'
|
import { CalendarContext } from './Calendar.context'
|
||||||
import { Month } from './Month'
|
import { Month } from './Month'
|
||||||
|
import { getNewDates, isSameDay, safeConvertDate } from '../../utils/date.utils'
|
||||||
|
import {
|
||||||
|
CommonProps,
|
||||||
|
useCommonProps,
|
||||||
|
omitCommonProps,
|
||||||
|
} from '../../utils/useCommonProps'
|
||||||
|
|
||||||
export type CalendarProps = Omit<
|
export type CalendarType = null | 'endDate' | 'startDate'
|
||||||
React.HTMLAttributes<HTMLDivElement>,
|
|
||||||
'label' | 'onChange'
|
export type CalendarProps = CommonProps &
|
||||||
> & {
|
Omit<React.HTMLAttributes<HTMLDivElement>, 'label' | 'onChange'> & {
|
||||||
open?: boolean
|
open?: boolean
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
value?: string
|
calendarType?: CalendarType
|
||||||
onChange: (data: Date) => void
|
onStartDateChange?: (startDate: Date) => void
|
||||||
handleRef: React.RefObject<HTMLElement>
|
onEndDateChange?: (endDate: Date) => void
|
||||||
size?: 'large' | 'medium' | 'small'
|
handleRef: React.RefObject<HTMLElement>
|
||||||
onClose?: () => void
|
size?: 'large' | 'medium' | 'small'
|
||||||
onCalendarClickaway?: (event: Event) => void
|
mode?: 'date' | 'range'
|
||||||
minDate?: Date
|
onClose?: () => void
|
||||||
maxDate?: Date
|
onCalendarClickaway?: (event: Event) => void
|
||||||
}
|
startDate: string
|
||||||
|
endDate?: string
|
||||||
|
minDate?: Date
|
||||||
|
maxDate?: Date
|
||||||
|
}
|
||||||
|
|
||||||
export const Calendar: React.FC<CalendarProps> & {
|
export const Calendar: React.FC<CalendarProps> & {
|
||||||
classes: typeof calendarClasses
|
classes: typeof calendarClasses
|
||||||
} = ({
|
} = ({
|
||||||
open,
|
open,
|
||||||
handleRef,
|
handleRef,
|
||||||
value: valueProp,
|
|
||||||
size = 'large',
|
size = 'large',
|
||||||
|
mode = 'date',
|
||||||
disabled = false,
|
disabled = false,
|
||||||
onChange,
|
onStartDateChange,
|
||||||
|
onEndDateChange,
|
||||||
onClose,
|
onClose,
|
||||||
onCalendarClickaway,
|
onCalendarClickaway,
|
||||||
|
startDate: startDateProp,
|
||||||
|
endDate: endDateProp,
|
||||||
|
calendarType = 'startDate',
|
||||||
// minDate and maxDate are necessary because onDateFocus freaks out with small/large date values.
|
// 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),
|
maxDate = new Date(2100, 0, 1),
|
||||||
children,
|
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
|
const commonProps = useCommonProps(props)
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
const [style, setStyle] = useState<React.CSSProperties>({})
|
const [style, setStyle] = useState<React.CSSProperties>({})
|
||||||
const [value, setValue] = useState<Date | null>(
|
const [startDate, setStartDate] = useState<Date | null>(
|
||||||
valueProp
|
startDateProp
|
||||||
? safeConvertDateToString(valueProp, minDate, maxDate).date
|
? safeConvertDate(startDateProp, minDate, maxDate).date
|
||||||
: null,
|
: 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) => {
|
useClickAway(ref, (event) => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
|
|
||||||
onCalendarClickaway && onCalendarClickaway(event)
|
onCalendarClickaway && onCalendarClickaway(event)
|
||||||
|
if (typeof open === 'undefined') {
|
||||||
if (isOpenControlled) return
|
onClose && onClose()
|
||||||
|
}
|
||||||
onClose && onClose()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleDateChange = (data: OnDatesChangeProps) => {
|
const handleDateChange = (data: OnDatesChangeProps) => {
|
||||||
if (typeof valueProp !== 'undefined')
|
const newDates = getNewDates(calendarType, startDate, endDate, data)
|
||||||
return onChange?.(data.startDate ?? new Date())
|
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 {
|
const {
|
||||||
|
@ -83,29 +106,52 @@ export const Calendar: React.FC<CalendarProps> & {
|
||||||
onDateFocus,
|
onDateFocus,
|
||||||
goToPreviousMonths,
|
goToPreviousMonths,
|
||||||
goToNextMonths,
|
goToNextMonths,
|
||||||
|
goToNextYear,
|
||||||
|
goToPreviousYear,
|
||||||
} = useDatepicker({
|
} = useDatepicker({
|
||||||
startDate: value ? new Date(value) : null,
|
startDate,
|
||||||
endDate: null,
|
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,
|
focusedInput: START_DATE,
|
||||||
onDatesChange: handleDateChange,
|
onDatesChange: handleDateChange,
|
||||||
numberOfMonths: 1,
|
numberOfMonths: 1,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Handle startDateProp and endDateProp changes. Only updates them if they differ from current state.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onDateFocus(value ? new Date(value) : new Date())
|
const newStart = safeConvertDate(startDateProp, minDate, maxDate)
|
||||||
}, [value])
|
|
||||||
|
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(() => {
|
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)
|
useEffect(() => {
|
||||||
setValue(date)
|
// When the endDate state changes, focus the calendar on that date.
|
||||||
}, [valueProp])
|
if (endDate) {
|
||||||
|
onDateFocus(endDate)
|
||||||
|
}
|
||||||
|
}, [endDate])
|
||||||
|
|
||||||
const updateStyle = () => {
|
const updateStyle = () => {
|
||||||
const { width, height, top, left } =
|
const { width, height, top, left } =
|
||||||
handleRef.current!.getBoundingClientRect()
|
handleRef.current!.getBoundingClientRect()
|
||||||
|
|
||||||
setStyle({
|
setStyle({
|
||||||
left,
|
left,
|
||||||
width,
|
width,
|
||||||
|
@ -121,6 +167,9 @@ export const Calendar: React.FC<CalendarProps> & {
|
||||||
<CalendarContext.Provider
|
<CalendarContext.Provider
|
||||||
value={{
|
value={{
|
||||||
size,
|
size,
|
||||||
|
mode,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
focusedDate,
|
focusedDate,
|
||||||
isDateFocused,
|
isDateFocused,
|
||||||
isDateSelected,
|
isDateSelected,
|
||||||
|
@ -130,11 +179,19 @@ export const Calendar: React.FC<CalendarProps> & {
|
||||||
onDateSelect,
|
onDateSelect,
|
||||||
onDateFocus,
|
onDateFocus,
|
||||||
onDateHover,
|
onDateHover,
|
||||||
|
goToPreviousMonths,
|
||||||
|
goToNextMonths,
|
||||||
|
goToNextYear,
|
||||||
|
goToPreviousYear,
|
||||||
|
changeYearMode,
|
||||||
|
setChangeYearMode,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
{...props}
|
{...props}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
|
{ ...omitCommonProps(props) },
|
||||||
|
commonProps.className,
|
||||||
props.className,
|
props.className,
|
||||||
calendarClasses.root,
|
calendarClasses.root,
|
||||||
open && calendarClasses.open,
|
open && calendarClasses.open,
|
||||||
|
@ -151,8 +208,6 @@ export const Calendar: React.FC<CalendarProps> & {
|
||||||
month={month.month}
|
month={month.month}
|
||||||
firstDayOfWeek={0}
|
firstDayOfWeek={0}
|
||||||
size={size}
|
size={size}
|
||||||
goToPreviousMonths={goToPreviousMonths}
|
|
||||||
goToNextMonths={goToNextMonths}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,72 +1,80 @@
|
||||||
import { useRef, useContext } from 'react'
|
import { useRef } from 'react'
|
||||||
import { useDay } from '@datepicker-react/hooks'
|
import { useDay } from '@datepicker-react/hooks'
|
||||||
import { CalendarContext } from './Calendar.context'
|
import { useCalendarContext } from './Calendar.context'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { calendarClasses } from './Calendar.classes'
|
|
||||||
import { Typography } from '../Typography'
|
import { Typography } from '../Typography'
|
||||||
|
import {
|
||||||
|
getDayBorders,
|
||||||
|
isDateWithinRange,
|
||||||
|
isSameDay,
|
||||||
|
resetHours,
|
||||||
|
} from '../../utils/date.utils'
|
||||||
|
import { calendarClasses } from './Calendar.classes'
|
||||||
|
|
||||||
export type DayProps = {
|
export type DayProps = {
|
||||||
day?: string
|
day?: string
|
||||||
date: Date
|
index: number
|
||||||
|
fullMonthDays: Date[]
|
||||||
disabled?: boolean
|
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 dayRef = useRef(null)
|
||||||
const {
|
const dayHandlers = useDay({ date, dayRef, ...calendarContext })
|
||||||
focusedDate,
|
const isToday = resetHours(date) === resetHours(new Date())
|
||||||
isDateFocused,
|
const isInDateRange =
|
||||||
isDateSelected,
|
mode === 'range' && isDateWithinRange(date, startDate, endDate)
|
||||||
isDateHovered,
|
|
||||||
isDateBlocked,
|
|
||||||
isFirstOrLastSelectedDate,
|
|
||||||
onDateSelect,
|
|
||||||
onDateFocus,
|
|
||||||
onDateHover,
|
|
||||||
} = useContext(CalendarContext)
|
|
||||||
|
|
||||||
const { onClick, onKeyDown, onMouseEnter, tabIndex } = useDay({
|
const isStartDate = isSameDay(date, startDate)
|
||||||
date,
|
const isEndDate = mode === 'range' && isSameDay(date, endDate)
|
||||||
focusedDate,
|
const isSelected = isStartDate || isEndDate || isInDateRange
|
||||||
isDateFocused,
|
|
||||||
isDateSelected,
|
|
||||||
isDateHovered,
|
|
||||||
isDateBlocked,
|
|
||||||
isFirstOrLastSelectedDate,
|
|
||||||
onDateFocus,
|
|
||||||
onDateSelect,
|
|
||||||
onDateHover,
|
|
||||||
dayRef,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!day) {
|
if (!day) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const isToday =
|
const borderClasses = getDayBorders(
|
||||||
new Date(date).setHours(0, 0, 0, 0) === new Date().setHours(0, 0, 0, 0)
|
index,
|
||||||
|
fullMonthDays,
|
||||||
|
isSelected,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<td
|
||||||
onClick={(e) => !disabled && onClick()}
|
onClick={dayHandlers.onClick}
|
||||||
onKeyDown={(e) => !disabled && onKeyDown(e)}
|
onMouseEnter={dayHandlers.onMouseEnter}
|
||||||
onMouseEnter={(e) => !disabled && onMouseEnter()}
|
tabIndex={dayHandlers.tabIndex}
|
||||||
tabIndex={tabIndex}
|
|
||||||
type="button"
|
|
||||||
ref={dayRef}
|
ref={dayRef}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
calendarClasses.day,
|
calendarClasses.dayContainer,
|
||||||
!disabled && isDateFocused(date) && calendarClasses.daySelected,
|
// 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,
|
disabled && calendarClasses.dayDisabled,
|
||||||
isToday && calendarClasses.dayIsToday,
|
isToday && calendarClasses.dayIsToday,
|
||||||
|
borderClasses,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Typography variant="label2">{parseInt(day, 10)}</Typography>
|
<div className={calendarClasses.day}>
|
||||||
{isToday && (
|
<Typography variant="label2">{parseInt(day, 10)}</Typography>
|
||||||
<Typography variant="label2" className={calendarClasses.todayIndicator}>
|
{isToday && (
|
||||||
▬
|
<Typography
|
||||||
</Typography>
|
variant="label2"
|
||||||
)}
|
className={calendarClasses.todayIndicator}
|
||||||
</button>
|
>
|
||||||
|
▬
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,181 +1,41 @@
|
||||||
import { FirstDayOfWeek, useMonth } from '@datepicker-react/hooks'
|
import { FirstDayOfWeek, useMonth } from '@datepicker-react/hooks'
|
||||||
import clsx from 'clsx'
|
import { useState } from 'react'
|
||||||
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 { useCalendarContext } from './Calendar.context'
|
import { useCalendarContext } from './Calendar.context'
|
||||||
import { Day } from './Day'
|
import { Days, MonthHeader, WeekdayHeader } from './MonthHelpers'
|
||||||
|
import { calendarClasses } from './Calendar.classes'
|
||||||
|
|
||||||
export type MonthProps = {
|
export type MonthProps = {
|
||||||
year: number
|
year: number
|
||||||
month: number
|
month: number
|
||||||
firstDayOfWeek: FirstDayOfWeek
|
firstDayOfWeek: FirstDayOfWeek
|
||||||
goToPreviousMonths: () => void
|
|
||||||
goToNextMonths: () => void
|
|
||||||
size?: 'large' | 'medium' | 'small'
|
size?: 'large' | 'medium' | 'small'
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Month = ({
|
export const Month = ({
|
||||||
size: _size = 'large',
|
size: _size = 'large',
|
||||||
year: _year,
|
year,
|
||||||
month,
|
month,
|
||||||
firstDayOfWeek,
|
firstDayOfWeek,
|
||||||
goToPreviousMonths,
|
|
||||||
goToNextMonths,
|
|
||||||
}: MonthProps) => {
|
}: MonthProps) => {
|
||||||
const sizeContext = useCalendarContext()
|
const sizeContext = useCalendarContext()
|
||||||
const size = sizeContext?.size ?? _size
|
const size = sizeContext?.size ?? _size
|
||||||
const [year, setYear] = useState(_year)
|
|
||||||
const { days, weekdayLabels, monthLabel } = useMonth({
|
const { days, weekdayLabels, monthLabel } = useMonth({
|
||||||
year,
|
year,
|
||||||
month,
|
month,
|
||||||
firstDayOfWeek,
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={calendarClasses.header}>
|
<MonthHeader monthLabel={monthLabel} size={size} />
|
||||||
<button
|
<table className={calendarClasses.monthTable}>
|
||||||
className={clsx(calendarClasses.button)}
|
<thead>
|
||||||
type="button"
|
<WeekdayHeader weekdayLabels={weekdayLabels} />
|
||||||
onClick={goToPreviousMonths}
|
</thead>
|
||||||
>
|
<tbody>
|
||||||
<ChevronLeftIcon color="primary" />
|
<Days days={days} />
|
||||||
</button>
|
</tbody>
|
||||||
<div className={calendarClasses.row}>
|
</table>
|
||||||
<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>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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`,
|
input: `lsd-date-field__input-container__input`,
|
||||||
inputFilled: `lsd-date-field__input-container__input--filled`,
|
inputFilled: `lsd-date-field__input-container__input--filled`,
|
||||||
icon: `lsd-date-field__input-container__icon`,
|
icon: `lsd-date-field__input-container__icon`,
|
||||||
|
noIcon: `lsd-date-field__input-container__no-icon`,
|
||||||
iconButton: `lsd-date-field__input-container__icon-button`,
|
iconButton: `lsd-date-field__input-container__icon-button`,
|
||||||
|
|
||||||
supportingText: 'lsd-date-field__supporting-text',
|
supportingText: 'lsd-date-field__supporting-text',
|
||||||
|
|
|
@ -12,9 +12,26 @@ export const DateFieldStyles = css`
|
||||||
}
|
}
|
||||||
|
|
||||||
.${dateFieldClasses.icon} {
|
.${dateFieldClasses.icon} {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
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} {
|
.${dateFieldClasses.outlined} {
|
||||||
|
@ -26,6 +43,9 @@ export const DateFieldStyles = css`
|
||||||
}
|
}
|
||||||
|
|
||||||
.${dateFieldClasses.inputContainer} {
|
.${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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
@ -70,7 +90,7 @@ export const DateFieldStyles = css`
|
||||||
height: 40px;
|
height: 40px;
|
||||||
}
|
}
|
||||||
.${dateFieldClasses.input} {
|
.${dateFieldClasses.input} {
|
||||||
padding: 9px 13px 9px 17px;
|
padding: 9px 0px 9px 17px;
|
||||||
}
|
}
|
||||||
.${dateFieldClasses.icon} {
|
.${dateFieldClasses.icon} {
|
||||||
padding: 12px 13px;
|
padding: 12px 13px;
|
||||||
|
@ -82,6 +102,7 @@ export const DateFieldStyles = css`
|
||||||
|
|
||||||
.${dateFieldClasses.medium} {
|
.${dateFieldClasses.medium} {
|
||||||
width: 188px;
|
width: 188px;
|
||||||
|
|
||||||
.${dateFieldClasses.label} {
|
.${dateFieldClasses.label} {
|
||||||
margin: 0 0 6px 14px;
|
margin: 0 0 6px 14px;
|
||||||
}
|
}
|
||||||
|
@ -101,6 +122,7 @@ export const DateFieldStyles = css`
|
||||||
|
|
||||||
.${dateFieldClasses.small} {
|
.${dateFieldClasses.small} {
|
||||||
width: 164px;
|
width: 164px;
|
||||||
|
|
||||||
.${dateFieldClasses.label} {
|
.${dateFieldClasses.label} {
|
||||||
margin: 0 0 6px 12px;
|
margin: 0 0 6px 12px;
|
||||||
}
|
}
|
||||||
|
@ -109,6 +131,7 @@ export const DateFieldStyles = css`
|
||||||
}
|
}
|
||||||
.${dateFieldClasses.input} {
|
.${dateFieldClasses.input} {
|
||||||
padding: 5px 9px 5px 11px;
|
padding: 5px 9px 5px 11px;
|
||||||
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
.${dateFieldClasses.icon} {
|
.${dateFieldClasses.icon} {
|
||||||
padding: 6px 9px;
|
padding: 6px 9px;
|
||||||
|
|
|
@ -4,11 +4,14 @@ import { useInput } from '../../utils/useInput'
|
||||||
import { CloseIcon, ErrorIcon } from '../Icons'
|
import { CloseIcon, ErrorIcon } from '../Icons'
|
||||||
import { Typography } from '../Typography'
|
import { Typography } from '../Typography'
|
||||||
import { dateFieldClasses } from './DateField.classes'
|
import { dateFieldClasses } from './DateField.classes'
|
||||||
|
import {
|
||||||
|
CommonProps,
|
||||||
|
useCommonProps,
|
||||||
|
omitCommonProps,
|
||||||
|
} from '../../utils/useCommonProps'
|
||||||
|
|
||||||
export type DateFieldProps = Omit<
|
export type DateFieldProps = CommonProps &
|
||||||
React.HTMLAttributes<HTMLDivElement>,
|
Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange' | 'value'> &
|
||||||
'onChange' | 'value'
|
|
||||||
> &
|
|
||||||
Pick<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> & {
|
Pick<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> & {
|
||||||
label?: React.ReactNode
|
label?: React.ReactNode
|
||||||
size?: 'large' | 'medium' | 'small'
|
size?: 'large' | 'medium' | 'small'
|
||||||
|
@ -45,9 +48,11 @@ export const DateField: React.FC<DateFieldProps> & {
|
||||||
icon,
|
icon,
|
||||||
onIconClick,
|
onIconClick,
|
||||||
inputProps = {},
|
inputProps = {},
|
||||||
|
calendarIconRef,
|
||||||
variant = 'underlined',
|
variant = 'underlined',
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
|
const commonProps = useCommonProps(props)
|
||||||
const ref = useRef<HTMLInputElement>(null)
|
const ref = useRef<HTMLInputElement>(null)
|
||||||
const input = useInput({
|
const input = useInput({
|
||||||
defaultValue,
|
defaultValue,
|
||||||
|
@ -65,7 +70,9 @@ export const DateField: React.FC<DateFieldProps> & {
|
||||||
aria-disabled={disabled ? 'true' : 'false'}
|
aria-disabled={disabled ? 'true' : 'false'}
|
||||||
{...props}
|
{...props}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
|
{ ...omitCommonProps(props) },
|
||||||
props.className,
|
props.className,
|
||||||
|
commonProps.className,
|
||||||
dateFieldClasses.root,
|
dateFieldClasses.root,
|
||||||
dateFieldClasses[size],
|
dateFieldClasses[size],
|
||||||
disabled && dateFieldClasses.disabled,
|
disabled && dateFieldClasses.disabled,
|
||||||
|
@ -109,7 +116,7 @@ export const DateField: React.FC<DateFieldProps> & {
|
||||||
<span
|
<span
|
||||||
className={dateFieldClasses.icon}
|
className={dateFieldClasses.icon}
|
||||||
onClick={() => !disabled && onIconClick && onIconClick()}
|
onClick={() => !disabled && onIconClick && onIconClick()}
|
||||||
ref={props.calendarIconRef}
|
ref={calendarIconRef}
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
</span>
|
</span>
|
||||||
|
@ -124,7 +131,10 @@ export const DateField: React.FC<DateFieldProps> & {
|
||||||
>
|
>
|
||||||
<CloseIcon color="primary" />
|
<CloseIcon color="primary" />
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : (
|
||||||
|
// Default case: just show and empty span on top of the browser's default icon.
|
||||||
|
<span className={dateFieldClasses.noIcon} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{supportingText && (
|
{supportingText && (
|
||||||
<div className={clsx(dateFieldClasses.supportingText)}>
|
<div className={clsx(dateFieldClasses.supportingText)}>
|
||||||
|
|
|
@ -10,11 +10,17 @@ import { DateField } from '../DateField'
|
||||||
import { CalendarIcon } from '../Icons'
|
import { CalendarIcon } from '../Icons'
|
||||||
import { Portal } from '../PortalProvider/Portal'
|
import { Portal } from '../PortalProvider/Portal'
|
||||||
import { datePickerClasses } from './DatePicker.classes'
|
import { datePickerClasses } from './DatePicker.classes'
|
||||||
|
import { wasElementClicked } from '../../utils/dom.util'
|
||||||
|
|
||||||
export type DatePickerProps = Omit<
|
import {
|
||||||
React.HTMLAttributes<HTMLDivElement>,
|
CommonProps,
|
||||||
'onChange' | 'value'
|
omitCommonProps,
|
||||||
> &
|
pickCommonProps,
|
||||||
|
useCommonProps,
|
||||||
|
} from '../../utils/useCommonProps'
|
||||||
|
|
||||||
|
export type DatePickerProps = CommonProps &
|
||||||
|
Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange' | 'value'> &
|
||||||
Pick<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> & {
|
Pick<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> & {
|
||||||
label?: React.ReactNode
|
label?: React.ReactNode
|
||||||
error?: boolean
|
error?: boolean
|
||||||
|
@ -42,6 +48,7 @@ export const DatePicker: React.FC<DatePickerProps> & {
|
||||||
variant = 'underlined',
|
variant = 'underlined',
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
|
const commonProps = useCommonProps(props)
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
const calendarIconRef = useRef<HTMLSpanElement>(null)
|
const calendarIconRef = useRef<HTMLSpanElement>(null)
|
||||||
const [openCalendar, setOpenCalendar] = useState(false)
|
const [openCalendar, setOpenCalendar] = useState(false)
|
||||||
|
@ -68,7 +75,9 @@ export const DatePicker: React.FC<DatePickerProps> & {
|
||||||
ref={ref}
|
ref={ref}
|
||||||
{...props}
|
{...props}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
|
{ ...omitCommonProps(props) },
|
||||||
props.className,
|
props.className,
|
||||||
|
commonProps.className,
|
||||||
datePickerClasses.root,
|
datePickerClasses.root,
|
||||||
datePickerClasses[size],
|
datePickerClasses[size],
|
||||||
)}
|
)}
|
||||||
|
@ -88,22 +97,20 @@ export const DatePicker: React.FC<DatePickerProps> & {
|
||||||
<Portal id="calendar">
|
<Portal id="calendar">
|
||||||
{withCalendar && (
|
{withCalendar && (
|
||||||
<Calendar
|
<Calendar
|
||||||
onChange={(date) => handleDateChange(date)}
|
{...pickCommonProps(props)}
|
||||||
|
onStartDateChange={(date) => handleDateChange(date)}
|
||||||
open={openCalendar}
|
open={openCalendar}
|
||||||
onCalendarClickaway={(event) => {
|
onCalendarClickaway={(event) => {
|
||||||
// If the calendar icon was clicked, return and don't close the calendar here.
|
// If the calendar icon was clicked, return and don't close the calendar here.
|
||||||
// Let the onIconClick above handle the closing.
|
// Let the onIconClick above handle the closing.
|
||||||
if (
|
if (wasElementClicked(event, calendarIconRef.current)) {
|
||||||
calendarIconRef.current &&
|
|
||||||
event?.composedPath().includes(calendarIconRef.current)
|
|
||||||
) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setOpenCalendar(false)
|
setOpenCalendar(false)
|
||||||
}}
|
}}
|
||||||
handleRef={ref}
|
handleRef={ref}
|
||||||
value={input.value}
|
startDate={input.value}
|
||||||
disabled={props.disabled}
|
disabled={props.disabled}
|
||||||
className={datePickerClasses.calendar}
|
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/Toast'
|
||||||
export * from './components/ToastProvider'
|
export * from './components/ToastProvider'
|
||||||
export * from './components/ButtonGroup'
|
export * from './components/ButtonGroup'
|
||||||
|
export * from './components/DateRangePicker'
|
||||||
|
|
|
@ -1,14 +1,24 @@
|
||||||
export const safeConvertDateToString = (
|
import { OnDatesChangeProps, UseMonthResult } from '@datepicker-react/hooks'
|
||||||
value: string,
|
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,
|
minDate: Date,
|
||||||
maxDate: Date,
|
maxDate: Date,
|
||||||
) => {
|
): SafeConvertDateResult => {
|
||||||
|
if (!value) return { isValid: false, date: null }
|
||||||
const date = new Date(value ?? undefined)
|
const date = new Date(value ?? undefined)
|
||||||
const isValid = !Number.isNaN(+date) && date >= minDate && date <= maxDate
|
const isValid = !Number.isNaN(+date) && date >= minDate && date <= maxDate
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isValid,
|
isValid,
|
||||||
date: isValid ? date : new Date(),
|
date,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export const removeDateTimezoneOffset = (date: Date) =>
|
export const removeDateTimezoneOffset = (date: Date) =>
|
||||||
|
@ -16,3 +26,208 @@ export const removeDateTimezoneOffset = (date: Date) =>
|
||||||
|
|
||||||
export const dateToISODateString = (date: Date) =>
|
export const dateToISODateString = (date: Date) =>
|
||||||
date.toISOString().split('T')[0]
|
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