From 3e7260f6d1663d7dce7e5f58e57d7835ced23767 Mon Sep 17 00:00:00 2001 From: jinhojang6 Date: Thu, 23 Mar 2023 13:20:02 +0900 Subject: [PATCH] feat: implement range picker --- .../components/CSSBaseline/CSSBaseline.tsx | 2 + .../components/Calendar/Calendar.classes.ts | 1 + .../components/Calendar/Calendar.context.ts | 3 + .../components/Calendar/Calendar.styles.ts | 6 + .../src/components/Calendar/Calendar.tsx | 13 +- .../lsd-react/src/components/Calendar/Day.tsx | 24 ++- .../RangePicker/RangePicker.classes.ts | 6 + .../RangePicker/RangePicker.stories.tsx | 48 ++++++ .../RangePicker/RangePicker.styles.ts | 20 +++ .../components/RangePicker/RangePicker.tsx | 139 ++++++++++++++++++ .../src/components/RangePicker/index.ts | 1 + packages/lsd-react/src/index.ts | 1 + packages/lsd-react/src/utils/date.utils.ts | 13 ++ 13 files changed, 272 insertions(+), 5 deletions(-) create mode 100644 packages/lsd-react/src/components/RangePicker/RangePicker.classes.ts create mode 100644 packages/lsd-react/src/components/RangePicker/RangePicker.stories.tsx create mode 100644 packages/lsd-react/src/components/RangePicker/RangePicker.styles.ts create mode 100644 packages/lsd-react/src/components/RangePicker/RangePicker.tsx create mode 100644 packages/lsd-react/src/components/RangePicker/index.ts diff --git a/packages/lsd-react/src/components/CSSBaseline/CSSBaseline.tsx b/packages/lsd-react/src/components/CSSBaseline/CSSBaseline.tsx index 7b1290a..77835f2 100644 --- a/packages/lsd-react/src/components/CSSBaseline/CSSBaseline.tsx +++ b/packages/lsd-react/src/components/CSSBaseline/CSSBaseline.tsx @@ -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 | SerializedStyles> = DateFieldStyles, DatePickerStyles, CalendarStyles, + RangePickerStyles, ] export const CSSBaseline: React.FC<{ theme?: Theme }> = ({ diff --git a/packages/lsd-react/src/components/Calendar/Calendar.classes.ts b/packages/lsd-react/src/components/Calendar/Calendar.classes.ts index ea33f38..cce6ba5 100644 --- a/packages/lsd-react/src/components/Calendar/Calendar.classes.ts +++ b/packages/lsd-react/src/components/Calendar/Calendar.classes.ts @@ -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', } diff --git a/packages/lsd-react/src/components/Calendar/Calendar.context.ts b/packages/lsd-react/src/components/Calendar/Calendar.context.ts index 97a2163..49e600f 100644 --- a/packages/lsd-react/src/components/Calendar/Calendar.context.ts +++ b/packages/lsd-react/src/components/Calendar/Calendar.context.ts @@ -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 diff --git a/packages/lsd-react/src/components/Calendar/Calendar.styles.ts b/packages/lsd-react/src/components/Calendar/Calendar.styles.ts index f483f03..adf8255 100644 --- a/packages/lsd-react/src/components/Calendar/Calendar.styles.ts +++ b/packages/lsd-react/src/components/Calendar/Calendar.styles.ts @@ -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)); + } ` diff --git a/packages/lsd-react/src/components/Calendar/Calendar.tsx b/packages/lsd-react/src/components/Calendar/Calendar.tsx index df53bc2..3688f7a 100644 --- a/packages/lsd-react/src/components/Calendar/Calendar.tsx +++ b/packages/lsd-react/src/components/Calendar/Calendar.tsx @@ -21,6 +21,9 @@ export type CalendarProps = Omit< onChange: (data: Date) => void handleRef: React.RefObject size?: 'large' | 'medium' + mode?: 'date' | 'range' + start?: Date + end?: Date onClose?: () => void } @@ -31,9 +34,12 @@ export const Calendar: React.FC & { handleRef, value: valueProp, size = 'large', + mode = 'date', disabled = false, onChange, onClose, + start, + end, children, ...props }) => { @@ -78,8 +84,8 @@ export const Calendar: React.FC & { }) 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 & { { + 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, )} > {parseInt(day, 10)} - {new Date(date).setHours(0, 0, 0, 0) === - new Date().setHours(0, 0, 0, 0) && ( + {setHour(new Date(date)) === setHour(new Date()) && ( diff --git a/packages/lsd-react/src/components/RangePicker/RangePicker.classes.ts b/packages/lsd-react/src/components/RangePicker/RangePicker.classes.ts new file mode 100644 index 0000000..c868a2a --- /dev/null +++ b/packages/lsd-react/src/components/RangePicker/RangePicker.classes.ts @@ -0,0 +1,6 @@ +export const rangePickerClasses = { + root: `lsd-range-picker`, + + withCalendar: `lsd-range-picker--with-calendar`, + open: `lsd-range-picker--open`, +} diff --git a/packages/lsd-react/src/components/RangePicker/RangePicker.stories.tsx b/packages/lsd-react/src/components/RangePicker/RangePicker.stories.tsx new file mode 100644 index 0000000..9e79b86 --- /dev/null +++ b/packages/lsd-react/src/components/RangePicker/RangePicker.stories.tsx @@ -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 = ({ ...args }) => { + return +} + +export const Controlled: Story = ({ ...args }) => { + return +} + +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', +} diff --git a/packages/lsd-react/src/components/RangePicker/RangePicker.styles.ts b/packages/lsd-react/src/components/RangePicker/RangePicker.styles.ts new file mode 100644 index 0000000..6bfed84 --- /dev/null +++ b/packages/lsd-react/src/components/RangePicker/RangePicker.styles.ts @@ -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; + } +` diff --git a/packages/lsd-react/src/components/RangePicker/RangePicker.tsx b/packages/lsd-react/src/components/RangePicker/RangePicker.tsx new file mode 100644 index 0000000..99c32aa --- /dev/null +++ b/packages/lsd-react/src/components/RangePicker/RangePicker.tsx @@ -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, + 'onChange' | 'value' +> & + Pick, '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 + } + +export type mode = 'start' | 'end' + +export const RangePicker: React.FC & { + classes: typeof rangePickerClasses +} = ({ + startValue: startValueProp, + endValue: endValueProp, + onChange, + size, + withCalendar = true, + ...props +}) => { + const ref = useRef(null) + const [openCalendar, setOpenCalendar] = useState(false) + const [mode, setMode] = useState('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 + setValue: any + }, + ) => input.setValue(dateToISODateString(removeDateTimezoneOffset(date))) + + const handleIconClick = (mode: mode) => { + setMode((prev) => { + if (prev === mode) { + setOpenCalendar((prev) => !prev) + return prev + } + return mode + }) + } + + return ( +
+ } + onIconClick={() => handleIconClick('start')} + value={startInput.value} + onChange={startInput.onChange} + style={{ width: size === 'large' ? '152px' : '145px' }} + {...props} + /> + } + 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} + /> + + {withCalendar && ( + + 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)} + /> + )} + +
+ ) +} + +RangePicker.classes = rangePickerClasses diff --git a/packages/lsd-react/src/components/RangePicker/index.ts b/packages/lsd-react/src/components/RangePicker/index.ts new file mode 100644 index 0000000..f81d104 --- /dev/null +++ b/packages/lsd-react/src/components/RangePicker/index.ts @@ -0,0 +1 @@ +export * from './RangePicker' diff --git a/packages/lsd-react/src/index.ts b/packages/lsd-react/src/index.ts index 2c4768d..709b56a 100644 --- a/packages/lsd-react/src/index.ts +++ b/packages/lsd-react/src/index.ts @@ -23,3 +23,4 @@ export * from './components/CheckboxGroup' export * from './components/DateField' export * from './components/DatePicker' export * from './components/Calendar' +export * from './components/RangePicker' diff --git a/packages/lsd-react/src/utils/date.utils.ts b/packages/lsd-react/src/utils/date.utils.ts index a5dba50..727eab7 100644 --- a/packages/lsd-react/src/utils/date.utils.ts +++ b/packages/lsd-react/src/utils/date.utils.ts @@ -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 +}