diff --git a/apps/docs/src/stories/Datepicker.stories.tsx b/apps/docs/src/stories/Datepicker.stories.tsx new file mode 100644 index 00000000..61bf3420 --- /dev/null +++ b/apps/docs/src/stories/Datepicker.stories.tsx @@ -0,0 +1,201 @@ +import { useState, useEffect } from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import { fn } from '@storybook/test'; +import { Datepicker, type DatepickerProps } from '@sopt-makers/ui'; + +const meta = { + title: 'Components/DatePicker', + component: Datepicker, + tags: ['autodocs'], + parameters: { + layout: 'centered', + backgrounds: { + default: 'light', + values: [ + { name: 'light', value: '#ffffff' }, + { name: 'dark', value: '#1a1a1a' }, + ], + }, + }, + argTypes: { + disabled: { + control: 'boolean', + description: '컴포넌트를 비활성화합니다.', + }, + className: { + control: 'text', + description: '컴포넌트에 적용할 커스텀 className입니다.', + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// Storybook의 date control은 timestamp를 반환하므로 Date 객체로 변환하는 헬퍼 함수 +const toDateOrNull = (value: Date | null | undefined | number): Date | null => { + if (!value) return null; + if (value instanceof Date) return value; + if (typeof value === 'number') return new Date(value); + return null; +}; + +/** + * 단일 날짜 선택 + */ +const SingleDateExample = (args: DatepickerProps): JSX.Element => { + const [value, setValue] = useState(!args.isRange && 'value' in args ? toDateOrNull(args.value) : null); + + useEffect(() => { + if (!args.isRange && 'value' in args) { + setValue(toDateOrNull(args.value)); + } + }, [args]); + + const handleChange = (date: Date | null) => { + setValue(date); + if (!args.isRange && 'onChange' in args) { + args.onChange?.(date); + } + }; + + return ( + + ); +}; + +/** + * 기본 Datepicker + * + * 단일 날짜를 선택할 수 있는 기본 Datepicker입니다. + * - Day 뷰와 Month 뷰를 전환할 수 있습니다. + * - 입력 필드에 직접 날짜를 입력할 수 있습니다. (YYYY.MM.DD 형식) + */ +export const Default: Story = { + render: (args) => , + args: { + disabled: false, + placeholder: 'YYYY.MM.DD', + onChange: fn(), + }, +}; + +/** + * 기본 값이 있는 Datepicker + * + * 초기 값을 설정한 Datepicker입니다. + */ +export const WithDefaultValue: Story = { + render: (args) => , + args: { + disabled: false, + value: new Date(2000, 3, 29), + onChange: fn(), + }, +}; + +/** + * 비활성화 상태 + * + * 비활성화된 Datepicker입니다. + */ +export const Disabled: Story = { + render: (args) => , + args: { + disabled: true, + value: new Date(2025, 0, 1), + onChange: fn(), + }, +}; + +/** + * 날짜 범위 선택 + */ +const DateRangeExample = (args: DatepickerProps) => { + const [startDate, setStartDate] = useState('startDate' in args ? toDateOrNull(args.startDate) : null); + const [endDate, setEndDate] = useState('endDate' in args ? toDateOrNull(args.endDate) : null); + + useEffect(() => { + if ('startDate' in args) setStartDate(toDateOrNull(args.startDate)); + if ('endDate' in args) setEndDate(toDateOrNull(args.endDate)); + }, [args]); + + const handleRangeChange = (start: Date | null, end: Date | null) => { + setStartDate(start); + setEndDate(end); + if ('onRangeChange' in args) args.onRangeChange?.(start, end); + }; + + return ( + + ); +}; + +/** + * 기본 Date Range + * + * 날짜 범위를 선택할 수 있는 Datepicker입니다. + * - 시작 날짜를 먼저 선택하고, 종료 날짜를 선택합니다. + * - 시작 날짜와 종료 날짜가 자동으로 정렬됩니다. + * - 범위 내의 날짜는 시각적으로 표시됩니다. + */ +export const DateRange: Story = { + render: (args) => , + args: { + isRange: true, + onRangeChange: fn(), + }, +}; + +/** + * 기본값이 있는 Date Range + * + * 초기 범위가 설정된 Date Range Picker입니다. + */ +export const DateRangeWithDefaultValue: Story = { + render: () => { + const today = new Date(); + const nextWeek = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 7); + + return ; + }, +}; + +/** + * 비활성화된 Date Range + * + * 비활성화된 Date Range Picker입니다. + */ +export const DisabledDateRange: Story = { + render: () => { + const today = new Date(); + const nextWeek = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 7); + + return ( + + ); + }, +}; diff --git a/packages/ui/Datepicker/Datepicker.tsx b/packages/ui/Datepicker/Datepicker.tsx new file mode 100644 index 00000000..6a2aac23 --- /dev/null +++ b/packages/ui/Datepicker/Datepicker.tsx @@ -0,0 +1,21 @@ +import { DatepickerContext } from './DatepickerContext'; +import DatepickerInput from './components/DatepickerInput'; +import DatepickerModal from './components/DatepickerModal'; +import * as S from './style.css'; +import { useDatepickerState } from './useDatepickerState'; +import type { DatepickerProps } from './types'; + +function Datepicker(props: DatepickerProps) { + const contextValue = useDatepickerState(props); + + return ( + +
+ + +
+
+ ); +} + +export default Datepicker; diff --git a/packages/ui/Datepicker/DatepickerContext.tsx b/packages/ui/Datepicker/DatepickerContext.tsx new file mode 100644 index 00000000..98f7aa11 --- /dev/null +++ b/packages/ui/Datepicker/DatepickerContext.tsx @@ -0,0 +1,48 @@ +import { createContext, useContext, type RefObject, type KeyboardEvent } from 'react'; + +export interface DatepickerContextValue { + // state + isOpen: boolean; + isRange: boolean; + disabled: boolean; + internalValue: Date | null; + internalStartDate: Date | null; + internalEndDate: Date | null; + defaultSelectedDate: Date; + singleInputText: string; + startInputText: string; + endInputText: string; + anchorRect: DOMRect | null; + + primaryInputRef: RefObject; + + // props + placeholder?: string; + startPlaceholder?: string; + endPlaceholder?: string; + + // setters + setSingleInputText: (value: string) => void; + setStartInputText: (value: string) => void; + setEndInputText: (value: string) => void; + + // handlers + handleInputClick: () => void; + handleDateChange: (date: Date) => void; + handleRangeChange: (startDate: Date | null, endDate: Date | null) => void; + handleClose: () => void; + commitSingleInput: () => void; + commitRangeInput: () => void; + handleSingleInputKeyDown: (event: KeyboardEvent) => void; + handleRangeInputKeyDown: (event: KeyboardEvent) => void; +} + +export const DatepickerContext = createContext(null); + +export function useDatepickerContext() { + const context = useContext(DatepickerContext); + if (!context) { + throw new Error('useDatepickerContext must be used within DatepickerContext.Provider'); + } + return context; +} diff --git a/packages/ui/Datepicker/components/DatepickerDayView.tsx b/packages/ui/Datepicker/components/DatepickerDayView.tsx new file mode 100644 index 00000000..ccf0aa92 --- /dev/null +++ b/packages/ui/Datepicker/components/DatepickerDayView.tsx @@ -0,0 +1,123 @@ +import clsx from 'clsx'; +import { useMemo } from 'react'; +import { generateCalendarDates, isSameDate } from '../utils'; +import { DAYS_OF_WEEK, DATEPICKER_MODE } from '../constants'; +import type { DatepickerMode } from '../types'; +import * as S from '../style.css'; +import DatepickerHeader from './DatepickerHeader'; + +interface DatepickerDayViewProps { + selectedDate: Date; + onDateSelect: (date: Date) => void; + onModeChange: (mode: DatepickerMode) => void; + onMonthChange: (date: Date) => void; + + // range props + isRange?: boolean; + startDate?: Date | null; + endDate?: Date | null; +} + +function DatepickerDayView({ + selectedDate, + onDateSelect, + onModeChange, + onMonthChange, + isRange = false, + startDate, + endDate, +}: DatepickerDayViewProps) { + const year = selectedDate.getFullYear(); + const month = selectedDate.getMonth(); + + const today = useMemo(() => new Date(), []); + const dates = useMemo(() => generateCalendarDates(year, month), [year, month]); + + const rangeCalculations = useMemo(() => { + const hasEndDate = Boolean(endDate); + const hasSameRangeDay = startDate && endDate ? isSameDate(startDate, endDate) : false; + const hasCompleteRange = Boolean(startDate && endDate && !hasSameRangeDay); + + return { + hasEndDate, + hasSameRangeDay, + hasCompleteRange, + }; + }, [startDate, endDate]); + + const handlePrevMonth = () => { + const prevMonth = new Date(year, month - 1, selectedDate.getDate()); + onMonthChange(prevMonth); + }; + + const handleNextMonth = () => { + const nextMonth = new Date(year, month + 1, selectedDate.getDate()); + onMonthChange(nextMonth); + }; + + const monthName = `${month + 1}월`; + + return ( +
+ { + onModeChange(DATEPICKER_MODE.MONTH); + }} + showTitleIcon + title={`${year}년 ${monthName}`} + /> + +
+ {DAYS_OF_WEEK.map((day) => ( +
+ {day} +
+ ))} +
+ +
+ {dates.map((date, index) => { + const currentDate = new Date(date.year, date.month, date.day); + const isSelected = isSameDate(currentDate, selectedDate); + const isToday = isSameDate(currentDate, today); + + const { hasEndDate, hasSameRangeDay, hasCompleteRange } = rangeCalculations; + + const isCurrentStart = startDate ? isSameDate(currentDate, startDate) : false; + const isCurrentEnd = endDate ? isSameDate(currentDate, endDate) : false; + + const isRangeStart = isRange && hasCompleteRange && isCurrentStart; + const isRangeEnd = isRange && hasCompleteRange && isCurrentEnd; + const isRangeStartOnly = isRange && isCurrentStart && (!hasEndDate || hasSameRangeDay); + const isInRange = + startDate && endDate && isRange && hasCompleteRange && currentDate > startDate && currentDate < endDate; + + return ( + + ); + })} +
+
+ ); +} + +export default DatepickerDayView; diff --git a/packages/ui/Datepicker/components/DatepickerHeader.tsx b/packages/ui/Datepicker/components/DatepickerHeader.tsx new file mode 100644 index 00000000..245719d4 --- /dev/null +++ b/packages/ui/Datepicker/components/DatepickerHeader.tsx @@ -0,0 +1,40 @@ +import { IconChevronDown, IconArrowLeft, IconArrowRight } from '@sopt-makers/icons'; +import * as S from '../style.css'; + +interface DatepickerHeaderProps { + title: string; + onTitleClick?: () => void; + onPrevClick: () => void; + onNextClick: () => void; + isPrevDisabled?: boolean; + isNextDisabled?: boolean; + showTitleIcon?: boolean; +} + +function DatepickerHeader({ + title, + onTitleClick, + onPrevClick, + onNextClick, + isPrevDisabled = false, + isNextDisabled = false, + showTitleIcon = false, +}: DatepickerHeaderProps): JSX.Element { + return ( +
+ + + + + +
+ ); +} + +export default DatepickerHeader; diff --git a/packages/ui/Datepicker/components/DatepickerInput.tsx b/packages/ui/Datepicker/components/DatepickerInput.tsx new file mode 100644 index 00000000..a55d3485 --- /dev/null +++ b/packages/ui/Datepicker/components/DatepickerInput.tsx @@ -0,0 +1,131 @@ +import { useCallback, useMemo } from 'react'; +import { IconCalendar } from '@sopt-makers/icons'; +import { TextField } from '../../Input'; +import { useDatepickerContext } from '../DatepickerContext'; +import { PLACEHOLDER_TEXT } from '../constants'; +import * as S from '../style.css'; + +function DatepickerInput() { + const { + disabled, + isRange, + placeholder, + startPlaceholder, + endPlaceholder, + singleInputText, + startInputText, + endInputText, + setSingleInputText, + setStartInputText, + setEndInputText, + handleInputClick, + commitSingleInput, + commitRangeInput, + handleSingleInputKeyDown, + handleRangeInputKeyDown, + primaryInputRef, + } = useDatepickerContext(); + + const handleIconClick = useCallback( + (event: React.MouseEvent | React.KeyboardEvent) => { + if (disabled) return; + event.stopPropagation(); + handleInputClick(); + }, + [disabled, handleInputClick], + ); + + const handleIconKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + handleIconClick(event); + } + }, + [handleIconClick], + ); + + const calendarIcon = useMemo( + () => ( +
+ +
+ ), + [disabled, handleIconClick, handleIconKeyDown], + ); + + if (isRange) { + return ( +
+
+ { + setStartInputText(e.target.value); + }} + onClick={handleInputClick} + onKeyDown={handleRangeInputKeyDown} + placeholder={startPlaceholder || PLACEHOLDER_TEXT} + ref={primaryInputRef} + rightAddon={calendarIcon} + type='text' + value={startInputText} + /> +
+ - +
+ { + setEndInputText(e.target.value); + }} + onClick={handleInputClick} + onKeyDown={handleRangeInputKeyDown} + placeholder={endPlaceholder || PLACEHOLDER_TEXT} + rightAddon={calendarIcon} + type='text' + value={endInputText} + /> +
+
+ ); + } + + return ( +
+ { + setSingleInputText(e.target.value); + }} + onClick={handleInputClick} + onKeyDown={handleSingleInputKeyDown} + placeholder={placeholder || PLACEHOLDER_TEXT} + ref={primaryInputRef} + rightAddon={calendarIcon} + type='text' + value={singleInputText} + /> +
+ ); +} + +export default DatepickerInput; diff --git a/packages/ui/Datepicker/components/DatepickerModal.tsx b/packages/ui/Datepicker/components/DatepickerModal.tsx new file mode 100644 index 00000000..79583f7e --- /dev/null +++ b/packages/ui/Datepicker/components/DatepickerModal.tsx @@ -0,0 +1,158 @@ +import { useState, useEffect, useRef } from 'react'; +import type { CSSProperties } from 'react'; +import { createPortal } from 'react-dom'; +import * as S from '../style.css'; +import type { DatepickerMode } from '../types'; +import { DATEPICKER_MODE } from '../constants'; +import { useDatepickerContext } from '../DatepickerContext'; +import DatepickerDayView from './DatepickerDayView'; +import DatepickerMonthView from './DatepickerMonthView'; + +function DatepickerModal() { + const { + isOpen, + handleClose, + isRange, + internalValue, + internalStartDate, + internalEndDate, + defaultSelectedDate, + handleDateChange, + handleRangeChange, + anchorRect, + } = useDatepickerContext(); + + const selectedDate = internalValue || internalEndDate || internalStartDate || defaultSelectedDate; + const [mode, setMode] = useState(DATEPICKER_MODE.DAY); + const [currentDate, setCurrentDate] = useState(selectedDate); + const [tempStartDate, setTempStartDate] = useState(internalStartDate); + const [tempEndDate, setTempEndDate] = useState(internalEndDate); + const skipSyncRef = useRef(false); + const dropdownRef = useRef(null); + + useEffect(() => { + if (skipSyncRef.current) { + skipSyncRef.current = false; + return; + } + setCurrentDate(selectedDate); + }, [selectedDate]); + + useEffect(() => { + setTempStartDate(internalStartDate || null); + }, [internalStartDate]); + + useEffect(() => { + setTempEndDate(internalEndDate || null); + }, [internalEndDate]); + + const prevIsOpenRef = useRef(isOpen); + + useEffect(() => { + const wasOpen = prevIsOpenRef.current; + if (isOpen && !wasOpen) { + skipSyncRef.current = false; + setMode(DATEPICKER_MODE.DAY); + } + prevIsOpenRef.current = isOpen; + }, [isOpen, selectedDate]); + + useEffect(() => { + if (!isOpen) return; + + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as Element; + if (!target.closest('[role="dialog"]') && !target.closest('input')) { + handleClose(); + } + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + handleClose(); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [isOpen, handleClose]); + + const moveToSelectedMonth = (targetDate: Date) => { + if (currentDate.getFullYear() !== targetDate.getFullYear() || currentDate.getMonth() !== targetDate.getMonth()) { + skipSyncRef.current = true; + setCurrentDate(targetDate); + } + }; + + const handleDateSelect = (date: Date) => { + if (isRange) { + if (!tempStartDate || tempEndDate) { + // 시작 날짜 선택 또는 새로운 범위 시작 + setTempStartDate(date); + setTempEndDate(null); + moveToSelectedMonth(date); + } else { + // 종료 날짜 선택 + const newStartDate = tempStartDate <= date ? tempStartDate : date; + const newEndDate = tempStartDate <= date ? date : tempStartDate; + setTempStartDate(newStartDate); + setTempEndDate(newEndDate); + handleRangeChange(newStartDate, newEndDate); + moveToSelectedMonth(date); + } + } else { + skipSyncRef.current = true; + setCurrentDate(date); + handleDateChange(date); + } + }; + + const handleMonthSelect = (month: number) => { + const newDate = new Date(currentDate.getFullYear(), month, currentDate.getDate()); + setCurrentDate(newDate); + }; + + const handleYearSelect = (year: number) => { + const newDate = new Date(year, currentDate.getMonth(), currentDate.getDate()); + setCurrentDate(newDate); + }; + + if (!isOpen || typeof document === 'undefined' || !anchorRect) return null; + + const dropdownStyle = { + left: anchorRect.left, + top: anchorRect.bottom + 8, + } as CSSProperties; + + return createPortal( +
+ {mode === DATEPICKER_MODE.DAY && ( + + )} + + {mode === DATEPICKER_MODE.MONTH && ( + + )} +
, + document.body, + ); +} + +export default DatepickerModal; diff --git a/packages/ui/Datepicker/components/DatepickerMonthView.tsx b/packages/ui/Datepicker/components/DatepickerMonthView.tsx new file mode 100644 index 00000000..940d9ab8 --- /dev/null +++ b/packages/ui/Datepicker/components/DatepickerMonthView.tsx @@ -0,0 +1,69 @@ +import clsx from 'clsx'; +import { MONTHS, DATEPICKER_MODE } from '../constants'; +import * as S from '../style.css'; +import type { DatepickerMode } from '../types'; +import DatepickerHeader from './DatepickerHeader'; + +interface DatepickerMonthViewProps { + selectedDate: Date; + onMonthSelect: (month: number) => void; + onModeChange: (mode: DatepickerMode) => void; + onYearChange: (year: number) => void; +} + +function DatepickerMonthView({ + selectedDate, + onMonthSelect, + onModeChange, + onYearChange, +}: DatepickerMonthViewProps): JSX.Element { + const year = selectedDate.getFullYear(); + const currentMonth = selectedDate.getMonth(); + const today = new Date(); + const todayMonth = today.getMonth(); + const todayYear = today.getFullYear(); + + const handleMonthClick = (monthIndex: number) => { + onMonthSelect(monthIndex); + onModeChange(DATEPICKER_MODE.DAY); + }; + + const handlePrevNavigation = () => { + onYearChange(year - 1); + }; + + const handleNextNavigation = () => { + onYearChange(year + 1); + }; + + return ( +
+ + +
+ {MONTHS.map((month, index) => { + const isSelected = index === currentMonth; + const isToday = index === todayMonth && year === todayYear; + + return ( + + ); + })} +
+
+ ); +} + +export default DatepickerMonthView; diff --git a/packages/ui/Datepicker/constants.ts b/packages/ui/Datepicker/constants.ts new file mode 100644 index 00000000..31eab87a --- /dev/null +++ b/packages/ui/Datepicker/constants.ts @@ -0,0 +1,12 @@ +export const DAYS_OF_WEEK = ['일', '월', '화', '수', '목', '금', '토']; + +export const MONTHS = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월']; + +export const DAYS_IN_WEEK = 7; + +export const PLACEHOLDER_TEXT = 'YYYY.MM.DD'; + +export const DATEPICKER_MODE = { + DAY: 'day', + MONTH: 'month', +} as const; diff --git a/packages/ui/Datepicker/index.tsx b/packages/ui/Datepicker/index.tsx new file mode 100644 index 00000000..31490dcb --- /dev/null +++ b/packages/ui/Datepicker/index.tsx @@ -0,0 +1,4 @@ +export { default as DatePicker } from './Datepicker'; +export { default } from './Datepicker'; + +export type { DatepickerMode, DatepickerProps, SingleDatepickerProps, RangeDatepickerProps } from './types'; diff --git a/packages/ui/Datepicker/style.css.ts b/packages/ui/Datepicker/style.css.ts new file mode 100644 index 00000000..81b09c40 --- /dev/null +++ b/packages/ui/Datepicker/style.css.ts @@ -0,0 +1,322 @@ +import { style, globalStyle } from '@vanilla-extract/css'; +import theme from '../theme.css'; + +export const container = style({ + position: 'relative', + display: 'inline-block', + width: '100%', +}); + +export const dropdown = style({ + position: 'fixed', + backgroundColor: theme.colors.gray900, + borderRadius: '12px', + padding: '12px', + zIndex: 1000, + width: '318px', + boxSizing: 'border-box', +}); + +export const rangeContainer = style({ + display: 'flex', + alignItems: 'center', + gap: '8px', +}); + +export const textFieldWrapper = style({ + width: '180px', +}); + +globalStyle(`${textFieldWrapper} input`, { + width: '100%', + height: '100%', +}); + +globalStyle(`${textFieldWrapper} input:disabled`, { + cursor: 'not-allowed', +}); + +export const rangeSeparator = style({ + color: theme.colors.gray300, +}); + +export const grid = style({ + display: 'grid', + justifyContent: 'center', + justifyItems: 'center', + width: '294px', + rowGap: '8px', +}); + +export const dayGrid = style({ + gridTemplateColumns: 'repeat(7, 1fr)', + gridAutoRows: '42px', +}); + +export const monthGrid = style({ + gridTemplateColumns: 'repeat(3, 1fr)', + gridTemplateRows: 'repeat(4, 42px)', + gap: '20px', + paddingBottom: '40px', +}); + +const commonCellColors = { + 'backgroundColor': 'transparent', + 'color': theme.colors.gray30, + + ':hover': { + backgroundColor: theme.colors.gray700, + color: theme.colors.gray30, + }, + + ':active': { + backgroundColor: theme.colors.gray10, + color: theme.colors.gray800, + }, +}; + +const selectedCellColors = { + 'backgroundColor': theme.colors.gray10, + 'color': theme.colors.gray800, + + ':hover': { + backgroundColor: theme.colors.gray10, + color: theme.colors.gray800, + }, + + ':active': { + backgroundColor: theme.colors.gray10, + color: theme.colors.gray800, + }, +}; + +const disabledCellColors = { + 'backgroundColor': 'transparent', + 'color': theme.colors.gray500, + + ':hover': { + backgroundColor: 'transparent', + color: theme.colors.gray500, + }, + + ':active': { + backgroundColor: 'transparent', + color: theme.colors.gray500, + }, +}; + +export const dayHeaders = style({ + display: 'grid', + gridTemplateColumns: 'repeat(7, 1fr)', + marginBottom: '8px', +}); + +export const dayHeader = style({ + color: theme.colors.gray400, + textAlign: 'center', + padding: '8px 0', + ...theme.fontsObject.LABEL_4_12_SB, +}); + +export const cell = style({ + 'position': 'relative', + 'width': '32px', + 'height': '32px', + 'border': 'none', + 'borderRadius': '50%', + 'cursor': 'pointer', + 'display': 'flex', + 'alignItems': 'center', + 'justifyContent': 'center', + 'margin': '5px', + 'fontSize': '18px', + ...commonCellColors, + + ':focus': { + outline: 'none', + }, +}); + +export const monthYearCell = style({ + width: '100%', + height: '38x', + ...theme.fontsObject.LABEL_1_18_SB, + borderRadius: '8px', +}); + +export const cellOtherMonth = style({ + color: theme.colors.gray500, +}); + +export const todayIndicator = style({ + position: 'absolute', + bottom: '-10px', + left: '50%', + transform: 'translateX(-50%)', + width: '4px', + height: '4px', + backgroundColor: theme.colors.gray400, + borderRadius: '50%', + pointerEvents: 'none', +}); + +export const cellSelected = style({ + ...selectedCellColors, +}); + +export const cellRangeStart = style({ + ...selectedCellColors, + 'position': 'relative', + 'display': 'flex', + 'width': '32px', + 'height': '32px', + 'zIndex': 2, + + '::before': { + content: '""', + position: 'absolute', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + width: '32px', + height: '32px', + backgroundColor: theme.colors.gray10, + borderRadius: '50%', + zIndex: -1, + }, + + '::after': { + content: '""', + position: 'absolute', + top: '50%', + transform: 'translateY(-50%)', + width: '40px', + left: '-3px', + height: '38px', + backgroundColor: theme.colors.gray700, + borderRadius: '19px 0 0 19px', + zIndex: -2, + }, +}); + +// 시작날짜만 선택된 경우 (range에서 endDate가 없을 때) +export const cellRangeStartOnly = style({ + ...selectedCellColors, + 'position': 'relative', + 'width': '32px', + 'height': '32px', + 'zIndex': 2, + + '::before': { + content: '""', + position: 'absolute', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + width: '32px', + height: '32px', + backgroundColor: theme.colors.gray10, + borderRadius: '50%', + zIndex: -1, + }, +}); + +export const cellRangeEnd = style({ + ...selectedCellColors, + 'position': 'relative', + 'zIndex': 2, + 'width': '32px', + 'height': '32px', + + '::before': { + content: '""', + position: 'absolute', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + width: '32px', + height: '32px', + backgroundColor: theme.colors.gray10, + borderRadius: '50%', + zIndex: -1, + }, + + '::after': { + content: '""', + position: 'absolute', + top: '50%', + transform: 'translateY(-50%)', + width: '40px', + height: '38px', + right: '-3px', + backgroundColor: theme.colors.gray700, + borderRadius: '0 19px 19px 0', + zIndex: -2, + }, +}); + +export const cellInRange = style({ + ...commonCellColors, + 'position': 'relative', + 'zIndex': 1, + + '::before': { + content: '""', + position: 'absolute', + top: '50%', + transform: 'translateY(-50%)', + width: '42px', + height: '38px', + backgroundColor: theme.colors.gray700, + zIndex: -1, + }, +}); + +export const cellDisabled = style({ + ...disabledCellColors, + cursor: 'not-allowed', +}); + +export const navigation = style({ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: '16px', +}); + +export const navButtons = style({ + display: 'flex', + gap: '8px', +}); + +export const navButton = style({ + 'width': '20px', + 'height': '20px', + 'border': 'none', + 'color': theme.colors.white, + 'cursor': 'pointer', + 'display': 'flex', + 'alignItems': 'center', + 'justifyContent': 'center', + 'transition': 'background-color 0.2s ease', + 'backgroundColor': 'transparent', + 'borderRadius': '8px', + + ':disabled': { + opacity: 0.3, + cursor: 'not-allowed', + }, +}); + +export const currentView = style({ + background: 'none', + border: 'none', + color: theme.colors.gray30, + cursor: 'pointer', + padding: '8px 0', + display: 'flex', + alignItems: 'center', + gap: '4px', + + ...theme.fontsObject.LABEL_2_16_SB, +}); diff --git a/packages/ui/Datepicker/types.ts b/packages/ui/Datepicker/types.ts new file mode 100644 index 00000000..f4c5c665 --- /dev/null +++ b/packages/ui/Datepicker/types.ts @@ -0,0 +1,26 @@ +import type { DATEPICKER_MODE } from './constants'; + +export type DatepickerMode = (typeof DATEPICKER_MODE)[keyof typeof DATEPICKER_MODE]; + +interface CommonDatepickerProps { + disabled?: boolean; + className?: string; +} + +export interface SingleDatepickerProps extends CommonDatepickerProps { + isRange?: false; + value?: Date | null; + onChange?: (date: Date | null) => void; + placeholder?: string; +} + +export interface RangeDatepickerProps extends CommonDatepickerProps { + isRange: true; + startDate?: Date | null; + endDate?: Date | null; + onRangeChange?: (startDate: Date | null, endDate: Date | null) => void; + startPlaceholder?: string; + endPlaceholder?: string; +} + +export type DatepickerProps = SingleDatepickerProps | RangeDatepickerProps; diff --git a/packages/ui/Datepicker/useDatepickerState.ts b/packages/ui/Datepicker/useDatepickerState.ts new file mode 100644 index 00000000..ceca01c1 --- /dev/null +++ b/packages/ui/Datepicker/useDatepickerState.ts @@ -0,0 +1,219 @@ +import { useState, useEffect, useMemo, useRef, useCallback } from 'react'; +import { formatDate, parseDateInput } from './utils'; +import type { DatepickerProps } from './types'; + +export function useDatepickerState(props: DatepickerProps) { + const { disabled = false } = props; + + const isRange = props.isRange === true; + const value = !isRange ? props.value : undefined; + const onChange = !isRange ? props.onChange : undefined; + const startDate = isRange ? props.startDate : undefined; + const endDate = isRange ? props.endDate : undefined; + const onRangeChange = isRange ? props.onRangeChange : undefined; + + const [isOpen, setIsOpen] = useState(false); + const [internalValue, setInternalValue] = useState(value ?? null); + const [internalStartDate, setInternalStartDate] = useState(startDate ?? null); + const [internalEndDate, setInternalEndDate] = useState(endDate ?? null); + const defaultSelectedDate = useMemo(() => new Date(), []); + const getFormattedDate = useCallback((date: Date | null) => (date ? formatDate(date) : ''), []); + const [singleInputText, setSingleInputText] = useState(() => getFormattedDate(value ?? null)); + const [startInputText, setStartInputText] = useState(() => getFormattedDate(startDate ?? null)); + const [endInputText, setEndInputText] = useState(() => getFormattedDate(endDate ?? null)); + const primaryInputRef = useRef(null); + const [anchorRect, setAnchorRect] = useState(null); + + useEffect(() => { + setInternalValue(value ?? null); + }, [value]); + + useEffect(() => { + setInternalStartDate(startDate ?? null); + }, [startDate]); + + useEffect(() => { + setInternalEndDate(endDate ?? null); + }, [endDate]); + + // anchor rect management for positioning + const updateAnchorRect = useCallback(() => { + if (!primaryInputRef.current) return; + const rect = primaryInputRef.current.getBoundingClientRect(); + setAnchorRect(rect); + }, []); + + const handleInputClick = useCallback(() => { + if (disabled) return; + updateAnchorRect(); + setIsOpen(true); + }, [disabled, updateAnchorRect]); + + // date change handlers + const handleDateChange = useCallback( + (date: Date) => { + setInternalValue(date); + setSingleInputText(formatDate(date)); + onChange?.(date); + }, + [onChange], + ); + + const handleRangeChange = useCallback( + (newStartDate: Date | null, newEndDate: Date | null) => { + setInternalStartDate(newStartDate); + setInternalEndDate(newEndDate); + setStartInputText(newStartDate ? formatDate(newStartDate) : ''); + setEndInputText(newEndDate ? formatDate(newEndDate) : ''); + onRangeChange?.(newStartDate, newEndDate); + }, + [onRangeChange], + ); + + // input commit handlers + const commitSingleInput = useCallback(() => { + const trimmed = singleInputText.trim(); + + if (trimmed === '') { + if (internalValue !== null) { + setInternalValue(null); + onChange?.(null); + } + return; + } + + const parsed = parseDateInput(trimmed); + + if (!parsed) { + setSingleInputText(getFormattedDate(internalValue)); + return; + } + + setInternalValue(parsed); + onChange?.(parsed); + }, [singleInputText, internalValue, onChange, getFormattedDate]); + + const commitRangeInput = useCallback(() => { + const startTrimmed = startInputText.trim(); + const endTrimmed = endInputText.trim(); + + const parsedStart = startTrimmed === '' ? null : parseDateInput(startTrimmed); + const parsedEnd = endTrimmed === '' ? null : parseDateInput(endTrimmed); + + if (startTrimmed !== '' && !parsedStart) { + setStartInputText(getFormattedDate(internalStartDate)); + return; + } + + if (endTrimmed !== '' && !parsedEnd) { + setEndInputText(getFormattedDate(internalEndDate)); + return; + } + + let nextStart = parsedStart; + let nextEnd = parsedEnd; + + if (nextStart && nextEnd && nextStart > nextEnd) { + const earlier = nextEnd; + const later = nextStart; + nextStart = earlier; + nextEnd = later; + } + + handleRangeChange(nextStart ?? null, nextEnd ?? null); + setStartInputText(getFormattedDate(nextStart ?? null)); + setEndInputText(getFormattedDate(nextEnd ?? null)); + }, [startInputText, endInputText, internalStartDate, internalEndDate, handleRangeChange, getFormattedDate]); + + // keyboard handlers + const handleSingleInputKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + event.preventDefault(); + commitSingleInput(); + return; + } + + if (event.key === 'ArrowDown' && event.altKey) { + event.preventDefault(); + handleInputClick(); + } + }, + [commitSingleInput, handleInputClick], + ); + + const handleRangeInputKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + event.preventDefault(); + commitRangeInput(); + return; + } + + if (event.key === 'ArrowDown' && event.altKey) { + event.preventDefault(); + handleInputClick(); + } + }, + [commitRangeInput, handleInputClick], + ); + + const handleClose = useCallback(() => { + setIsOpen(false); + setAnchorRect(null); + }, []); + + // reposition modal on scroll/resize + useEffect(() => { + if (!isOpen) return; + + const handleReposition = () => { + updateAnchorRect(); + }; + + handleReposition(); + + window.addEventListener('resize', handleReposition); + window.addEventListener('scroll', handleReposition, true); + + return () => { + window.removeEventListener('resize', handleReposition); + window.removeEventListener('scroll', handleReposition, true); + }; + }, [isOpen, updateAnchorRect]); + + return { + isOpen, + isRange, + disabled, + internalValue, + internalStartDate, + internalEndDate, + defaultSelectedDate, + singleInputText, + startInputText, + endInputText, + anchorRect, + + placeholder: !isRange ? props.placeholder : undefined, + startPlaceholder: isRange ? props.startPlaceholder : undefined, + endPlaceholder: isRange ? props.endPlaceholder : undefined, + + primaryInputRef, + + setSingleInputText, + setStartInputText, + setEndInputText, + + handleInputClick, + handleDateChange, + handleRangeChange, + handleClose, + commitSingleInput, + commitRangeInput, + handleSingleInputKeyDown, + handleRangeInputKeyDown, + }; +} + +export type UseDatepickerStateReturn = ReturnType; diff --git a/packages/ui/Datepicker/utils.ts b/packages/ui/Datepicker/utils.ts new file mode 100644 index 00000000..2e21d847 --- /dev/null +++ b/packages/ui/Datepicker/utils.ts @@ -0,0 +1,112 @@ +interface CalendarDate { + year: number; + month: number; + day: number; + isCurrentMonth: boolean; +} + +export function generateCalendarDates(year: number, month: number): CalendarDate[] { + const firstDayOfMonth = new Date(year, month, 1); + const lastDayOfMonth = new Date(year, month + 1, 0); + const firstDayOfWeek = firstDayOfMonth.getDay(); + const daysInMonth = lastDayOfMonth.getDate(); + + const dates: CalendarDate[] = []; + + // previous month dates + const prevMonth = month === 0 ? 11 : month - 1; + const prevYear = month === 0 ? year - 1 : year; + const daysInPrevMonth = new Date(prevYear, prevMonth + 1, 0).getDate(); + + for (let i = firstDayOfWeek - 1; i >= 0; i--) { + dates.push({ + year: prevYear, + month: prevMonth, + day: daysInPrevMonth - i, + isCurrentMonth: false, + }); + } + + // current month dates + for (let day = 1; day <= daysInMonth; day++) { + dates.push({ + year, + month, + day, + isCurrentMonth: true, + }); + } + + // next month dates + const nextMonth = month === 11 ? 0 : month + 1; + const nextYear = month === 11 ? year + 1 : year; + const totalCells = Math.ceil(dates.length / 7) * 7; + const remainingCells = totalCells - dates.length; + + for (let day = 1; day <= remainingCells; day++) { + dates.push({ + year: nextYear, + month: nextMonth, + day, + isCurrentMonth: false, + }); + } + + return dates; +} + +export function isSameDate(date1: Date, date2: Date): boolean { + return ( + date1.getFullYear() === date2.getFullYear() && + date1.getMonth() === date2.getMonth() && + date1.getDate() === date2.getDate() + ); +} + +export function formatDate(date: Date): string { + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + + return `${year}.${month.toString().padStart(2, '0')}.${day.toString().padStart(2, '0')}`; +} + +export function parseDateInput(value: string): Date | null { + const trimmed = value.trim(); + + if (trimmed === '') { + return null; + } + + const normalized = trimmed.replace(/[-/]/g, '.'); + const parts = normalized + .split('.') + .map((part) => part.trim()) + .filter((part) => part.length > 0); + + if (parts.length !== 3) { + return null; + } + + const [yearString, monthString, dayString] = parts; + const year = Number(yearString); + const month = Number(monthString); + const day = Number(dayString); + + if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) { + return null; + } + + const date = new Date(year, month - 1, day); + + if ( + Number.isNaN(date.getTime()) || + date.getFullYear() !== year || + date.getMonth() !== month - 1 || + date.getDate() !== day + ) { + return null; + } + + return date; +} diff --git a/packages/ui/index.ts b/packages/ui/index.ts index 9f4a2314..89cd8d9f 100644 --- a/packages/ui/index.ts +++ b/packages/ui/index.ts @@ -11,6 +11,8 @@ export { TextField, TextArea, SearchField, SelectV2, Select, UserMention } from export { default as Chip } from './Chip'; export { default as Callout } from './Callout'; export { default as Tab } from './Tab'; +export { default as Datepicker } from './Datepicker'; +export * from './Datepicker'; export { Tooltip } from './Tooltip'; export * from './Skeleton'; export * from './FieldBox';