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 { ListBoxStyles } from '../ListBox/ListBox.styles'
|
||||
import { QuoteStyles } from '../Quote/Quote.styles'
|
||||
import { RangePickerStyles } from '../RangePicker/RangePicker.styles'
|
||||
import { TabItemStyles } from '../TabItem/TabItem.styles'
|
||||
import { TabsStyles } from '../Tabs/Tabs.styles'
|
||||
import { TagStyles } from '../Tag/Tag.styles'
|
||||
|
@ -54,6 +55,7 @@ const componentStyles: Array<ReturnType<typeof withTheme> | SerializedStyles> =
|
|||
DateFieldStyles,
|
||||
DatePickerStyles,
|
||||
CalendarStyles,
|
||||
RangePickerStyles,
|
||||
]
|
||||
|
||||
export const CSSBaseline: React.FC<{ theme?: Theme }> = ({
|
||||
|
|
|
@ -19,4 +19,5 @@ export const calendarClasses = {
|
|||
daySelected: 'lsd-calendar-day--selected',
|
||||
dayDisabled: 'lsd-calendar-day--disabled',
|
||||
today: 'lsd-calendar-today',
|
||||
dayRange: 'lsd-calendar-day--range',
|
||||
}
|
||||
|
|
|
@ -3,6 +3,9 @@ import React from 'react'
|
|||
export type CalendarContextType = {
|
||||
focusedDate: Date | null
|
||||
size?: 'large' | 'medium'
|
||||
mode?: 'date' | 'range'
|
||||
start?: Date
|
||||
end?: Date
|
||||
isDateFocused: (date: Date) => boolean
|
||||
isDateSelected: (date: Date) => boolean
|
||||
isDateHovered: (date: Date) => boolean
|
||||
|
|
|
@ -39,6 +39,8 @@ export const CalendarStyles = css`
|
|||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
grid-gap: 1px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
@ -141,4 +143,8 @@ export const CalendarStyles = css`
|
|||
align-items: 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
|
||||
handleRef: React.RefObject<HTMLElement>
|
||||
size?: 'large' | 'medium'
|
||||
mode?: 'date' | 'range'
|
||||
start?: Date
|
||||
end?: Date
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
|
@ -31,9 +34,12 @@ export const Calendar: React.FC<CalendarProps> & {
|
|||
handleRef,
|
||||
value: valueProp,
|
||||
size = 'large',
|
||||
mode = 'date',
|
||||
disabled = false,
|
||||
onChange,
|
||||
onClose,
|
||||
start,
|
||||
end,
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
|
@ -78,8 +84,8 @@ export const Calendar: React.FC<CalendarProps> & {
|
|||
})
|
||||
|
||||
useEffect(() => {
|
||||
onDateFocus(value ? new Date(value) : new Date())
|
||||
}, [value])
|
||||
mode !== 'range' && onDateFocus(value ? new Date(value) : new Date())
|
||||
}, [value, mode])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof valueProp === 'undefined') return
|
||||
|
@ -107,6 +113,9 @@ export const Calendar: React.FC<CalendarProps> & {
|
|||
<CalendarContext.Provider
|
||||
value={{
|
||||
size,
|
||||
mode,
|
||||
start,
|
||||
end,
|
||||
focusedDate,
|
||||
isDateFocused,
|
||||
isDateSelected,
|
||||
|
|
|
@ -4,6 +4,12 @@ import { CalendarContext } from './Calendar.context'
|
|||
import clsx from 'clsx'
|
||||
import { calendarClasses } from './Calendar.classes'
|
||||
import { Typography } from '../Typography'
|
||||
import {
|
||||
checkDateRange,
|
||||
checkStartDate,
|
||||
selectStart,
|
||||
setHour,
|
||||
} from '../../utils/date.utils'
|
||||
|
||||
export type DayProps = {
|
||||
day?: string
|
||||
|
@ -12,6 +18,8 @@ export type DayProps = {
|
|||
}
|
||||
|
||||
export const Day = ({ day, date, disabled = false }: DayProps) => {
|
||||
const { mode, start, end } = useContext(CalendarContext)
|
||||
|
||||
const dayRef = useRef(null)
|
||||
const {
|
||||
focusedDate,
|
||||
|
@ -53,13 +61,23 @@ export const Day = ({ day, date, disabled = false }: DayProps) => {
|
|||
ref={dayRef}
|
||||
className={clsx(
|
||||
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,
|
||||
)}
|
||||
>
|
||||
<Typography variant="label2">{parseInt(day, 10)}</Typography>
|
||||
{new Date(date).setHours(0, 0, 0, 0) ===
|
||||
new Date().setHours(0, 0, 0, 0) && (
|
||||
{setHour(new Date(date)) === setHour(new Date()) && (
|
||||
<Typography variant="label2" className={calendarClasses.today}>
|
||||
■
|
||||
</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/DatePicker'
|
||||
export * from './components/Calendar'
|
||||
export * from './components/RangePicker'
|
||||
|
|
|
@ -13,3 +13,16 @@ export const removeDateTimezoneOffset = (date: Date) =>
|
|||
|
||||
export const dateToISODateString = (date: Date) =>
|
||||
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