feat: implement range picker

This commit is contained in:
jinhojang6 2023-03-23 13:20:02 +09:00
parent 4928b31524
commit 3e7260f6d1
No known key found for this signature in database
GPG Key ID: 0E7AA62CB0D9E6F3
13 changed files with 272 additions and 5 deletions

View File

@ -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 }> = ({

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,6 @@
export const rangePickerClasses = {
root: `lsd-range-picker`,
withCalendar: `lsd-range-picker--with-calendar`,
open: `lsd-range-picker--open`,
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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