diff options
| author | joonhoekim <26rote@gmail.com> | 2025-12-04 11:42:07 +0900 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-12-04 11:42:07 +0900 |
| commit | c26d78eabf13498c9817885b54512593c6a33f8d (patch) | |
| tree | 5da80c5acad09b897ce0de42c6771ee36f3d83cc | |
| parent | 0f3954bf57e65caef7b7dd14ea5fccb63fdb2bef (diff) | |
(김준회) 공통컴포넌트: YYYY-MM-DD 형태 수동 입력과 캘린더에서 선택 지원하는 date 입력 컴포넌트
| -rw-r--r-- | components/common/date-picker/date-picker-with-input.tsx | 322 | ||||
| -rw-r--r-- | components/common/date-picker/index.ts | 3 |
2 files changed, 325 insertions, 0 deletions
diff --git a/components/common/date-picker/date-picker-with-input.tsx b/components/common/date-picker/date-picker-with-input.tsx new file mode 100644 index 00000000..6e768601 --- /dev/null +++ b/components/common/date-picker/date-picker-with-input.tsx @@ -0,0 +1,322 @@ +"use client" + +import * as React from "react" +import { format, parse, isValid } from "date-fns" +import { ko } from "date-fns/locale" +import { CalendarIcon, ChevronLeft, ChevronRight } from "lucide-react" +import { DayPicker } from "react-day-picker" + +import { cn } from "@/lib/utils" +import { Button, buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" + +export interface DatePickerWithInputProps { + value?: Date + onChange?: (date: Date | undefined) => void + disabled?: boolean + placeholder?: string + className?: string + minDate?: Date + maxDate?: Date + dateFormat?: string + inputClassName?: string + locale?: "ko" | "en" +} + +/** + * DatePickerWithInput - 캘린더 선택 및 직접 입력이 가능한 날짜 선택기 + * + * 사용법: + * ```tsx + * <DatePickerWithInput + * value={selectedDate} + * onChange={(date) => setSelectedDate(date)} + * placeholder="날짜를 선택하세요" + * minDate={new Date()} + * /> + * ``` + */ +export function DatePickerWithInput({ + value, + onChange, + disabled = false, + placeholder = "YYYY-MM-DD", + className, + minDate, + maxDate, + dateFormat = "yyyy-MM-dd", + inputClassName, + locale: localeProp = "en", +}: DatePickerWithInputProps) { + const [open, setOpen] = React.useState(false) + const [inputValue, setInputValue] = React.useState<string>("") + const [month, setMonth] = React.useState<Date>(value || new Date()) + const [hasError, setHasError] = React.useState(false) + const [errorMessage, setErrorMessage] = React.useState<string>("") + + // 외부 value가 변경되면 inputValue도 업데이트 + React.useEffect(() => { + if (value && isValid(value)) { + setInputValue(format(value, dateFormat)) + setMonth(value) + setHasError(false) + setErrorMessage("") + } else { + setInputValue("") + } + }, [value, dateFormat]) + + // 날짜 유효성 검사 및 에러 메시지 설정 + const validateDate = (date: Date): { valid: boolean; message: string } => { + if (minDate) { + const minDateStart = new Date(minDate) + minDateStart.setHours(0, 0, 0, 0) + const dateToCheck = new Date(date) + dateToCheck.setHours(0, 0, 0, 0) + if (dateToCheck < minDateStart) { + return { + valid: false, + message: `${format(minDate, dateFormat)} 이후 날짜를 선택해주세요` + } + } + } + if (maxDate) { + const maxDateEnd = new Date(maxDate) + maxDateEnd.setHours(23, 59, 59, 999) + if (date > maxDateEnd) { + return { + valid: false, + message: `${format(maxDate, dateFormat)} 이전 날짜를 선택해주세요` + } + } + } + return { valid: true, message: "" } + } + + // 캘린더에서 날짜 선택 + const handleCalendarSelect = React.useCallback((date: Date | undefined, e?: React.MouseEvent) => { + // 이벤트 전파 중지 + if (e) { + e.preventDefault() + e.stopPropagation() + } + + if (date) { + const validation = validateDate(date) + if (validation.valid) { + setInputValue(format(date, dateFormat)) + setHasError(false) + setErrorMessage("") + onChange?.(date) + setMonth(date) + } else { + setHasError(true) + setErrorMessage(validation.message) + } + } + setOpen(false) + }, [dateFormat, onChange, minDate, maxDate]) + + // 직접 입력값 변경 + const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { + const newValue = e.target.value + setInputValue(newValue) + + // 입력 중에는 에러 상태 초기화 + if (hasError) { + setHasError(false) + setErrorMessage("") + } + + // YYYY-MM-DD 형식인 경우에만 파싱 시도 + if (/^\d{4}-\d{2}-\d{2}$/.test(newValue)) { + const parsedDate = parse(newValue, dateFormat, new Date()) + + if (isValid(parsedDate)) { + const validation = validateDate(parsedDate) + if (validation.valid) { + setHasError(false) + setErrorMessage("") + onChange?.(parsedDate) + setMonth(parsedDate) + } else { + setHasError(true) + setErrorMessage(validation.message) + } + } else { + setHasError(true) + setErrorMessage("유효하지 않은 날짜 형식입니다") + } + } + } + + // 입력 완료 시 (blur) 유효성 검사 + const handleInputBlur = () => { + if (!inputValue) { + setHasError(false) + setErrorMessage("") + onChange?.(undefined) + return + } + + const parsedDate = parse(inputValue, dateFormat, new Date()) + + if (isValid(parsedDate)) { + const validation = validateDate(parsedDate) + if (validation.valid) { + setHasError(false) + setErrorMessage("") + onChange?.(parsedDate) + } else { + setHasError(true) + setErrorMessage(validation.message) + // 유효 범위를 벗어난 경우 입력값은 유지하되 에러 표시 + } + } else { + // 유효하지 않은 형식인 경우 + setHasError(true) + setErrorMessage("YYYY-MM-DD 형식으로 입력해주세요") + } + } + + // 키보드 이벤트 처리 (Enter 키로 완료) + const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { + if (e.key === "Enter") { + handleInputBlur() + } + } + + // 날짜 비활성화 체크 (캘린더용) + const isDateDisabled = (date: Date) => { + if (disabled) return true + if (minDate) { + const minDateStart = new Date(minDate) + minDateStart.setHours(0, 0, 0, 0) + const dateToCheck = new Date(date) + dateToCheck.setHours(0, 0, 0, 0) + if (dateToCheck < minDateStart) return true + } + if (maxDate) { + const maxDateEnd = new Date(maxDate) + maxDateEnd.setHours(23, 59, 59, 999) + if (date > maxDateEnd) return true + } + return false + } + + // 캘린더 버튼 클릭 핸들러 + const handleCalendarButtonClick = (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + setOpen(!open) + } + + // Popover 상태 변경 핸들러 + const handleOpenChange = (newOpen: boolean) => { + setOpen(newOpen) + } + + return ( + <div className={cn("relative", className)}> + <div className="flex items-center gap-1"> + <Input + type="text" + value={inputValue} + onChange={handleInputChange} + onBlur={handleInputBlur} + onKeyDown={handleKeyDown} + placeholder={placeholder} + disabled={disabled} + className={cn( + "pr-10", + hasError && "border-red-500 focus-visible:ring-red-500", + inputClassName + )} + /> + <Popover open={open} onOpenChange={handleOpenChange} modal={true}> + <PopoverTrigger asChild> + <Button + variant="ghost" + size="icon" + className="absolute right-0 h-full px-3 hover:bg-transparent" + disabled={disabled} + type="button" + onClick={handleCalendarButtonClick} + > + <CalendarIcon className={cn( + "h-4 w-4", + hasError ? "text-red-500" : "text-muted-foreground" + )} /> + </Button> + </PopoverTrigger> + <PopoverContent + className="w-auto p-0" + align="end" + onPointerDownOutside={(e) => e.preventDefault()} + onInteractOutside={(e) => e.preventDefault()} + > + <div + onClick={(e) => e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + > + <DayPicker + mode="single" + selected={value} + onSelect={(date, selectedDay, activeModifiers, e) => { + handleCalendarSelect(date, e as unknown as React.MouseEvent) + }} + month={month} + onMonthChange={setMonth} + disabled={isDateDisabled} + locale={localeProp === "ko" ? ko : undefined} + showOutsideDays + className="p-3" + classNames={{ + months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0", + month: "space-y-4", + caption: "flex justify-center pt-1 relative items-center", + caption_label: "text-sm font-medium", + nav: "space-x-1 flex items-center", + nav_button: cn( + buttonVariants({ variant: "outline" }), + "h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100" + ), + nav_button_previous: "absolute left-1", + nav_button_next: "absolute right-1", + table: "w-full border-collapse space-y-1", + head_row: "flex", + head_cell: "text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]", + row: "flex w-full mt-2", + cell: "relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected])]:rounded-md", + day: cn( + buttonVariants({ variant: "ghost" }), + "h-8 w-8 p-0 font-normal aria-selected:opacity-100" + ), + day_selected: "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground", + day_today: "bg-accent text-accent-foreground", + day_outside: "text-muted-foreground opacity-50", + day_disabled: "text-muted-foreground opacity-50", + day_hidden: "invisible", + }} + components={{ + IconLeft: () => <ChevronLeft className="h-4 w-4" />, + IconRight: () => <ChevronRight className="h-4 w-4" />, + }} + /> + </div> + </PopoverContent> + </Popover> + </div> + {/* 에러 메시지 표시 */} + {hasError && errorMessage && ( + <p className="text-xs text-red-500 mt-1">{errorMessage}</p> + )} + </div> + ) +} + diff --git a/components/common/date-picker/index.ts b/components/common/date-picker/index.ts new file mode 100644 index 00000000..85c0c259 --- /dev/null +++ b/components/common/date-picker/index.ts @@ -0,0 +1,3 @@ +// 공용 날짜 선택기 컴포넌트 +export { DatePickerWithInput, type DatePickerWithInputProps } from './date-picker-with-input' + |
