mirror of https://github.com/acid-info/lsd.git
feat: implement range picker
This commit is contained in:
parent
4928b31524
commit
3e7260f6d1
|
@ -20,6 +20,7 @@ import { IconButtonStyles } from '../IconButton/IconButton.styles'
|
||||||
import { LsdIconStyles } from '../Icons/LsdIcon/LsdIcon.styles'
|
import { LsdIconStyles } from '../Icons/LsdIcon/LsdIcon.styles'
|
||||||
import { ListBoxStyles } from '../ListBox/ListBox.styles'
|
import { ListBoxStyles } from '../ListBox/ListBox.styles'
|
||||||
import { QuoteStyles } from '../Quote/Quote.styles'
|
import { QuoteStyles } from '../Quote/Quote.styles'
|
||||||
|
import { RangePickerStyles } from '../RangePicker/RangePicker.styles'
|
||||||
import { TabItemStyles } from '../TabItem/TabItem.styles'
|
import { TabItemStyles } from '../TabItem/TabItem.styles'
|
||||||
import { TabsStyles } from '../Tabs/Tabs.styles'
|
import { TabsStyles } from '../Tabs/Tabs.styles'
|
||||||
import { TagStyles } from '../Tag/Tag.styles'
|
import { TagStyles } from '../Tag/Tag.styles'
|
||||||
|
@ -54,6 +55,7 @@ const componentStyles: Array<ReturnType<typeof withTheme> | SerializedStyles> =
|
||||||
DateFieldStyles,
|
DateFieldStyles,
|
||||||
DatePickerStyles,
|
DatePickerStyles,
|
||||||
CalendarStyles,
|
CalendarStyles,
|
||||||
|
RangePickerStyles,
|
||||||
]
|
]
|
||||||
|
|
||||||
export const CSSBaseline: React.FC<{ theme?: Theme }> = ({
|
export const CSSBaseline: React.FC<{ theme?: Theme }> = ({
|
||||||
|
|
|
@ -19,4 +19,5 @@ export const calendarClasses = {
|
||||||
daySelected: 'lsd-calendar-day--selected',
|
daySelected: 'lsd-calendar-day--selected',
|
||||||
dayDisabled: 'lsd-calendar-day--disabled',
|
dayDisabled: 'lsd-calendar-day--disabled',
|
||||||
today: 'lsd-calendar-today',
|
today: 'lsd-calendar-today',
|
||||||
|
dayRange: 'lsd-calendar-day--range',
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,9 @@ import React from 'react'
|
||||||
export type CalendarContextType = {
|
export type CalendarContextType = {
|
||||||
focusedDate: Date | null
|
focusedDate: Date | null
|
||||||
size?: 'large' | 'medium'
|
size?: 'large' | 'medium'
|
||||||
|
mode?: 'date' | 'range'
|
||||||
|
start?: Date
|
||||||
|
end?: Date
|
||||||
isDateFocused: (date: Date) => boolean
|
isDateFocused: (date: Date) => boolean
|
||||||
isDateSelected: (date: Date) => boolean
|
isDateSelected: (date: Date) => boolean
|
||||||
isDateHovered: (date: Date) => boolean
|
isDateHovered: (date: Date) => boolean
|
||||||
|
|
|
@ -39,6 +39,8 @@ export const CalendarStyles = css`
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(7, 1fr);
|
grid-template-columns: repeat(7, 1fr);
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
grid-gap: 1px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -141,4 +143,8 @@ export const CalendarStyles = css`
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.${calendarClasses.dayRange} {
|
||||||
|
box-shadow: 0 0 0 1px rgb(var(--lsd-border-primary));
|
||||||
|
}
|
||||||
`
|
`
|
||||||
|
|
|
@ -21,6 +21,9 @@ export type CalendarProps = Omit<
|
||||||
onChange: (data: Date) => void
|
onChange: (data: Date) => void
|
||||||
handleRef: React.RefObject<HTMLElement>
|
handleRef: React.RefObject<HTMLElement>
|
||||||
size?: 'large' | 'medium'
|
size?: 'large' | 'medium'
|
||||||
|
mode?: 'date' | 'range'
|
||||||
|
start?: Date
|
||||||
|
end?: Date
|
||||||
onClose?: () => void
|
onClose?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -31,9 +34,12 @@ export const Calendar: React.FC<CalendarProps> & {
|
||||||
handleRef,
|
handleRef,
|
||||||
value: valueProp,
|
value: valueProp,
|
||||||
size = 'large',
|
size = 'large',
|
||||||
|
mode = 'date',
|
||||||
disabled = false,
|
disabled = false,
|
||||||
onChange,
|
onChange,
|
||||||
onClose,
|
onClose,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
|
@ -78,8 +84,8 @@ export const Calendar: React.FC<CalendarProps> & {
|
||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onDateFocus(value ? new Date(value) : new Date())
|
mode !== 'range' && onDateFocus(value ? new Date(value) : new Date())
|
||||||
}, [value])
|
}, [value, mode])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof valueProp === 'undefined') return
|
if (typeof valueProp === 'undefined') return
|
||||||
|
@ -107,6 +113,9 @@ export const Calendar: React.FC<CalendarProps> & {
|
||||||
<CalendarContext.Provider
|
<CalendarContext.Provider
|
||||||
value={{
|
value={{
|
||||||
size,
|
size,
|
||||||
|
mode,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
focusedDate,
|
focusedDate,
|
||||||
isDateFocused,
|
isDateFocused,
|
||||||
isDateSelected,
|
isDateSelected,
|
||||||
|
|
|
@ -4,6 +4,12 @@ import { CalendarContext } from './Calendar.context'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { calendarClasses } from './Calendar.classes'
|
import { calendarClasses } from './Calendar.classes'
|
||||||
import { Typography } from '../Typography'
|
import { Typography } from '../Typography'
|
||||||
|
import {
|
||||||
|
checkDateRange,
|
||||||
|
checkStartDate,
|
||||||
|
selectStart,
|
||||||
|
setHour,
|
||||||
|
} from '../../utils/date.utils'
|
||||||
|
|
||||||
export type DayProps = {
|
export type DayProps = {
|
||||||
day?: string
|
day?: string
|
||||||
|
@ -12,6 +18,8 @@ export type DayProps = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Day = ({ day, date, disabled = false }: DayProps) => {
|
export const Day = ({ day, date, disabled = false }: DayProps) => {
|
||||||
|
const { mode, start, end } = useContext(CalendarContext)
|
||||||
|
|
||||||
const dayRef = useRef(null)
|
const dayRef = useRef(null)
|
||||||
const {
|
const {
|
||||||
focusedDate,
|
focusedDate,
|
||||||
|
@ -53,13 +61,23 @@ export const Day = ({ day, date, disabled = false }: DayProps) => {
|
||||||
ref={dayRef}
|
ref={dayRef}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
calendarClasses.day,
|
calendarClasses.day,
|
||||||
!disabled && isDateFocused(date) && calendarClasses.daySelected,
|
!disabled &&
|
||||||
|
mode !== 'range' &&
|
||||||
|
isDateFocused(date) &&
|
||||||
|
calendarClasses.daySelected,
|
||||||
|
!disabled &&
|
||||||
|
mode === 'range' &&
|
||||||
|
checkDateRange(date, start, end) &&
|
||||||
|
calendarClasses.dayRange,
|
||||||
|
!disabled &&
|
||||||
|
mode === 'range' &&
|
||||||
|
checkStartDate(date, start) &&
|
||||||
|
calendarClasses.dayRange,
|
||||||
disabled && calendarClasses.dayDisabled,
|
disabled && calendarClasses.dayDisabled,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Typography variant="label2">{parseInt(day, 10)}</Typography>
|
<Typography variant="label2">{parseInt(day, 10)}</Typography>
|
||||||
{new Date(date).setHours(0, 0, 0, 0) ===
|
{setHour(new Date(date)) === setHour(new Date()) && (
|
||||||
new Date().setHours(0, 0, 0, 0) && (
|
|
||||||
<Typography variant="label2" className={calendarClasses.today}>
|
<Typography variant="label2" className={calendarClasses.today}>
|
||||||
■
|
■
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
|
@ -0,0 +1,6 @@
|
||||||
|
export const rangePickerClasses = {
|
||||||
|
root: `lsd-range-picker`,
|
||||||
|
|
||||||
|
withCalendar: `lsd-range-picker--with-calendar`,
|
||||||
|
open: `lsd-range-picker--open`,
|
||||||
|
}
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { Meta, Story } from '@storybook/react'
|
||||||
|
import { RangePicker, RangePickerProps } from './RangePicker'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: 'RangePicker',
|
||||||
|
component: RangePicker,
|
||||||
|
argTypes: {
|
||||||
|
size: {
|
||||||
|
type: {
|
||||||
|
name: 'enum',
|
||||||
|
value: ['medium', 'large'],
|
||||||
|
},
|
||||||
|
defaultValue: 'large',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as Meta
|
||||||
|
|
||||||
|
export const Uncontrolled: Story<RangePickerProps> = ({ ...args }) => {
|
||||||
|
return <RangePicker {...args} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Controlled: Story<RangePickerProps> = ({ ...args }) => {
|
||||||
|
return <RangePicker {...args} />
|
||||||
|
}
|
||||||
|
|
||||||
|
Uncontrolled.args = {
|
||||||
|
disabled: false,
|
||||||
|
error: false,
|
||||||
|
startValue: undefined,
|
||||||
|
endValue: undefined,
|
||||||
|
onChange: undefined,
|
||||||
|
errorIcon: false,
|
||||||
|
clearButton: true,
|
||||||
|
withCalendar: true,
|
||||||
|
size: 'large',
|
||||||
|
}
|
||||||
|
|
||||||
|
Controlled.args = {
|
||||||
|
disabled: false,
|
||||||
|
error: false,
|
||||||
|
startValue: '2023-01-02',
|
||||||
|
endValue: '2023-01-10',
|
||||||
|
onChange: undefined,
|
||||||
|
errorIcon: false,
|
||||||
|
clearButton: true,
|
||||||
|
withCalendar: true,
|
||||||
|
size: 'large',
|
||||||
|
}
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { css } from '@emotion/react'
|
||||||
|
import { dateFieldClasses } from '../DateField/DateField.classes'
|
||||||
|
import { rangePickerClasses } from './RangePicker.classes'
|
||||||
|
|
||||||
|
export const RangePickerStyles = css`
|
||||||
|
.${rangePickerClasses.root} {
|
||||||
|
width: fit-content;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.${rangePickerClasses.open} {
|
||||||
|
border-bottom: 1px solid rgb(var(--lsd-border-primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.${rangePickerClasses.open} .${dateFieldClasses.root} {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
`
|
|
@ -0,0 +1,139 @@
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
|
import {
|
||||||
|
dateToISODateString,
|
||||||
|
removeDateTimezoneOffset,
|
||||||
|
safeConvertDateToString,
|
||||||
|
} from '../../utils/date.utils'
|
||||||
|
import { useInput } from '../../utils/useInput'
|
||||||
|
import { Calendar } from '../Calendar'
|
||||||
|
import { DateField } from '../DateField'
|
||||||
|
import { CalendarIcon } from '../Icons'
|
||||||
|
import { Portal } from '../PortalProvider/Portal'
|
||||||
|
import { rangePickerClasses } from './RangePicker.classes'
|
||||||
|
|
||||||
|
export type RangePickerProps = Omit<
|
||||||
|
React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
'onChange' | 'value'
|
||||||
|
> &
|
||||||
|
Pick<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> & {
|
||||||
|
error?: boolean
|
||||||
|
errorIcon?: boolean
|
||||||
|
clearButton?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
withCalendar?: boolean
|
||||||
|
startValue?: string
|
||||||
|
endValue?: string
|
||||||
|
defaultValue?: string
|
||||||
|
placeholder?: string
|
||||||
|
size?: 'large' | 'medium'
|
||||||
|
inputProps?: React.InputHTMLAttributes<HTMLInputElement>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type mode = 'start' | 'end'
|
||||||
|
|
||||||
|
export const RangePicker: React.FC<RangePickerProps> & {
|
||||||
|
classes: typeof rangePickerClasses
|
||||||
|
} = ({
|
||||||
|
startValue: startValueProp,
|
||||||
|
endValue: endValueProp,
|
||||||
|
onChange,
|
||||||
|
size,
|
||||||
|
withCalendar = true,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
const [openCalendar, setOpenCalendar] = useState(false)
|
||||||
|
const [mode, setMode] = useState<mode>('start')
|
||||||
|
|
||||||
|
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 handleDateChange = (
|
||||||
|
date: Date,
|
||||||
|
input: {
|
||||||
|
value?: string
|
||||||
|
filled?: boolean
|
||||||
|
onChange?: React.ChangeEventHandler<HTMLInputElement>
|
||||||
|
setValue: any
|
||||||
|
},
|
||||||
|
) => input.setValue(dateToISODateString(removeDateTimezoneOffset(date)))
|
||||||
|
|
||||||
|
const handleIconClick = (mode: mode) => {
|
||||||
|
setMode((prev) => {
|
||||||
|
if (prev === mode) {
|
||||||
|
setOpenCalendar((prev) => !prev)
|
||||||
|
return prev
|
||||||
|
}
|
||||||
|
return mode
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={clsx(
|
||||||
|
props.className,
|
||||||
|
rangePickerClasses.root,
|
||||||
|
withCalendar && rangePickerClasses.withCalendar,
|
||||||
|
openCalendar && rangePickerClasses.open,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<DateField
|
||||||
|
icon={withCalendar && <CalendarIcon color="primary" />}
|
||||||
|
onIconClick={() => handleIconClick('start')}
|
||||||
|
value={startInput.value}
|
||||||
|
onChange={startInput.onChange}
|
||||||
|
style={{ width: size === 'large' ? '152px' : '145px' }}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
<DateField
|
||||||
|
icon={withCalendar && <CalendarIcon color="primary" />}
|
||||||
|
onIconClick={() => handleIconClick('end')}
|
||||||
|
value={endInput.value}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (new Date(e.target.value) >= new Date(startInput.value))
|
||||||
|
endInput.onChange(e)
|
||||||
|
}}
|
||||||
|
style={{ width: size === 'large' ? '152px' : '145px' }}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
<Portal id="calendar">
|
||||||
|
{withCalendar && (
|
||||||
|
<Calendar
|
||||||
|
onChange={(date) =>
|
||||||
|
handleDateChange(date, mode === 'start' ? startInput : endInput)
|
||||||
|
}
|
||||||
|
open={openCalendar}
|
||||||
|
onClose={() => setOpenCalendar(false)}
|
||||||
|
handleRef={ref}
|
||||||
|
mode="range"
|
||||||
|
value={mode === 'start' ? startInput.value : endInput.value}
|
||||||
|
disabled={props.disabled}
|
||||||
|
start={new Date(startInput.value)}
|
||||||
|
end={new Date(endInput.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Portal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
RangePicker.classes = rangePickerClasses
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './RangePicker'
|
|
@ -23,3 +23,4 @@ export * from './components/CheckboxGroup'
|
||||||
export * from './components/DateField'
|
export * from './components/DateField'
|
||||||
export * from './components/DatePicker'
|
export * from './components/DatePicker'
|
||||||
export * from './components/Calendar'
|
export * from './components/Calendar'
|
||||||
|
export * from './components/RangePicker'
|
||||||
|
|
|
@ -13,3 +13,16 @@ 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 setHour = (date: Date) => date.setHours(0, 0, 0, 0)
|
||||||
|
|
||||||
|
export const checkDateRange = (date: Date, start: Date, end: Date) => {
|
||||||
|
if (setHour(start) <= setHour(date) && setHour(end) >= setHour(date))
|
||||||
|
return true
|
||||||
|
else return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export const checkStartDate = (date: Date, start: Date) => {
|
||||||
|
if (setHour(date) === setHour(start)) return true
|
||||||
|
else return false
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue