/** * @title Date Picker * @description 캘린더에서 하루·기간·여러 날짜를 선택할 때 사용하는 컴포넌트예요. */'use client';import * as React from 'react';import { Popover as PopoverPrimitive } from 'radix-ui';import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';import { cn } from '@/lib/utils';import { ActionButton } from '@/components/ui/action-button';import { WheelPicker, type WheelPickerColumn } from '@/components/ui/wheel-picker';// SEED Rootage spec: date-picker.yaml// - root: color $color.bg.layer-floating// - header: height $dimension.x12 / headerLabel: paddingX x1, gap x1, icon x5, t5 bold, fg.neutral// - months: gap x6 (twoMonths) / continuousScroll: fogHeight 96px// - weekday: height x12 (week 24px), t4 medium, fg.neutral-subtle// - monthLabel: height x12, t5 bold, fg.neutral// - dateCell: minHeight x12 / dateVisual: 42px, radius full// enabled bg.transparent, pressed bg.transparent-pressed, today bg.neutral-weak,// selected bg.neutral-solid + fg.on-neutral-solid, readOnly palette.gray-600// - dateContent: t5 medium, fg.neutral-muted, disabled fg.disabled// - rangeBackground: bg.neutral-weak// - wheelPopover(week): width 240px, radius r3, bg.layer-floating, shadow s3/* ------------------------------------------------------------------------------------------------- * 날짜 값 · 계산 (순수 JS Date, UTC 기준으로 시간대 영향 없이 계산해요) * -----------------------------------------------------------------------------------------------*//** 시간과 시간대를 해석하지 않는 달력 날짜예요. month 는 1–12 예요. */export interface DatePickerDate { year: number; month: number; day: number;}export interface DatePickerRangeValue { start: DatePickerDate; end?: DatePickerDate;}export interface DatePickerMonth { year: number; month: number;}export type DatePickerSelectionMode = 'single' | 'range' | 'multiple';export type DatePickerVisibleRange = 'month' | 'twoMonths' | 'week' | 'continuous';const DAY = 86_400_000;const mod = (n: number, m: number) => ((n % m) + m) % m;const toTime = (d: DatePickerDate) => Date.UTC(d.year, d.month - 1, d.day);const fromTime = (t: number): DatePickerDate => { const date = new Date(t); return { year: date.getUTCFullYear(), month: date.getUTCMonth() + 1, day: date.getUTCDate() };};const addDays = (d: DatePickerDate, n: number) => fromTime(toTime(d) + n * DAY);const compareDates = (a: DatePickerDate, b: DatePickerDate) => toTime(a) - toTime(b);const isSameDate = (a: DatePickerDate | null | undefined, b: DatePickerDate | null | undefined) => Boolean(a && b && a.year === b.year && a.month === b.month && a.day === b.day);const daysInMonth = (year: number, month: number) => new Date(Date.UTC(year, month, 0)).getUTCDate();const dayOfWeek = (d: DatePickerDate) => new Date(toTime(d)).getUTCDay();const monthIndex = (m: DatePickerMonth) => m.year * 12 + (m.month - 1);const fromMonthIndex = (index: number): DatePickerMonth => ({ year: Math.floor(index / 12), month: mod(index, 12) + 1 });const addMonths = (m: DatePickerMonth, n: number) => fromMonthIndex(monthIndex(m) + n);const startOfWeek = (d: DatePickerDate, weekStartsOn: number) => addDays(d, -mod(dayOfWeek(d) - weekStartsOn, 7));const dayDiff = (a: DatePickerDate, b: DatePickerDate) => Math.round((toTime(b) - toTime(a)) / DAY);function getToday(): DatePickerDate { const now = new Date(); return { year: now.getFullYear(), month: now.getMonth() + 1, day: now.getDate() };}function getWeekStartsOn(locale: string) { try { const info = new Intl.Locale(locale) as unknown as { getWeekInfo?: () => { firstDay: number }; weekInfo?: { firstDay: number }; }; const firstDay = info.getWeekInfo?.().firstDay ?? info.weekInfo?.firstDay; return firstDay ? firstDay % 7 : 0; } catch { return 0; }}/* ------------------------------------------------------------------------------------------------- * 제약 조건 helper * -----------------------------------------------------------------------------------------------*/export interface DatePickerConstraintContext { selectionMode: DatePickerSelectionMode; /** Range 에서 종료일을 기다리는 시작일이에요. */ rangeStart: DatePickerDate | null; /** 현재 선택된 날짜 목록이에요. */ selectedDates: readonly DatePickerDate[];}/** 후보 날짜를 선택할 수 있으면 true 를 반환해요. */export type DatePickerConstraint = (date: DatePickerDate, context: DatePickerConstraintContext) => boolean;/** min 을 포함해 그 이후 날짜만 선택할 수 있어요. */export const dateOnOrAfter = (min: DatePickerDate): DatePickerConstraint => (date) => compareDates(date, min) >= 0;/** max 를 포함해 그 이전 날짜만 선택할 수 있어요. */export const dateOnOrBefore = (max: DatePickerDate): DatePickerConstraint => (date) => compareDates(date, max) <= 0;/** 지정한 날짜를 선택할 수 없게 해요. Range 에서는 시작일부터 후보 종료일까지 전체 구간을 검사해요. */export const excludeDates = (dates: readonly DatePickerDate[], options: { checkRange?: boolean } = {}): DatePickerConstraint => (date, { selectionMode, rangeStart }) => { const { checkRange = true } = options; if (checkRange && selectionMode === 'range' && rangeStart && compareDates(date, rangeStart) >= 0) { return !dates.some((d) => compareDates(d, rangeStart) >= 0 && compareDates(d, date) <= 0); } return !dates.some((d) => isSameDate(d, date)); };/** 시작일과 종료일을 모두 포함한 날짜 수가 count 이상이어야 해요. */export const rangeDayCountAtLeast = (count: number): DatePickerConstraint => (date, { rangeStart }) => !rangeStart || compareDates(date, rangeStart) < 0 || dayDiff(rangeStart, date) + 1 >= count;/** 시작일과 종료일을 모두 포함한 날짜 수가 count 이하여야 해요. */export const rangeDayCountAtMost = (count: number): DatePickerConstraint => (date, { rangeStart }) => !rangeStart || compareDates(date, rangeStart) < 0 || dayDiff(rangeStart, date) + 1 <= count;/** Multiple 에서 선택 가능한 최대 개수예요. 이미 선택한 날짜의 해제는 허용해요. */export const maxSelectionCount = (count: number): DatePickerConstraint => (date, { selectedDates }) => selectedDates.some((d) => isSameDate(d, date)) || selectedDates.length < count;/* ------------------------------------------------------------------------------------------------- * Props * -----------------------------------------------------------------------------------------------*/export interface DatePickerAriaLabels { root?: string; previous?: string; next?: string; year?: string; month?: string;}export interface DatePickerCellRenderProps { date: DatePickerDate; /** 포맷된 날짜 숫자예요. */ formattedDay: string; selected: boolean; today: boolean; inRange: boolean; rangeStart: boolean; rangeEnd: boolean; unavailable: boolean; focused: boolean;}interface DatePickerBaseProps extends Omit<React.ComponentProps<'div'>, 'defaultValue' | 'children' | 'onChange'> { /** 한 번에 보여주는 날짜 범위예요. */ visibleRange?: DatePickerVisibleRange; /** 월·요일·날짜 표기와 주 시작일을 정해요. */ locale?: string; /** 주 시작 요일이에요. 0 = 일요일. 생략하면 locale 을 따라요. */ weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6; /** 오늘 날짜예요. 생략하면 기기의 오늘이에요. */ today?: DatePickerDate; /** 이동할 수 있는 연도 범위예요. 기본값은 오늘 기준 앞뒤 100년이에요. */ yearRange?: { start: number; end: number }; /** Continuous 에서 노출할 월 범위예요. 양끝 월을 포함해요. 생략하면 표시 기준 월부터 12개월이에요. */ monthRange?: { start: DatePickerMonth; end: DatePickerMonth }; /** 모든 함수가 true 를 반환해야 선택할 수 있어요. */ constraints?: readonly DatePickerConstraint[]; /** 현재 표시하는 월·주의 기준 날짜 (제어). */ viewDate?: DatePickerDate; /** 처음 표시할 기준 날짜 (비제어). */ defaultViewDate?: DatePickerDate; onViewDateChange?: (viewDate: DatePickerDate) => void; /** 캘린더 전체를 비활성화해요. */ disabled?: boolean; /** 캘린더 전체를 보기 전용으로 만들어요. */ readOnly?: boolean; /** 날짜 숫자 아래에 가격·예약 가능 여부 같은 보조 정보를 표시해요. */ renderDateCellSupplement?: (props: DatePickerCellRenderProps) => React.ReactNode; /** 접근성 이름을 재정의해요. */ ariaLabels?: DatePickerAriaLabels;}export interface DatePickerSingleProps extends DatePickerBaseProps { selectionMode?: 'single'; value?: DatePickerDate | null; defaultValue?: DatePickerDate | null; onValueChange?: (value: DatePickerDate) => void; rangeStartReadOnly?: never;}export interface DatePickerRangeProps extends DatePickerBaseProps { selectionMode: 'range'; value?: DatePickerRangeValue | null; defaultValue?: DatePickerRangeValue | null; onValueChange?: (value: DatePickerRangeValue) => void; /** 기존 시작일을 고정하고 종료일만 변경할 수 있게 해요. */ rangeStartReadOnly?: boolean;}export interface DatePickerMultipleProps extends DatePickerBaseProps { selectionMode: 'multiple'; value?: readonly DatePickerDate[]; defaultValue?: readonly DatePickerDate[]; onValueChange?: (value: DatePickerDate[]) => void; rangeStartReadOnly?: never;}export type DatePickerProps = DatePickerSingleProps | DatePickerRangeProps | DatePickerMultipleProps;type AnyValue = DatePickerDate | DatePickerRangeValue | readonly DatePickerDate[] | null | undefined;const DEFAULT_ARIA_LABELS = { ko: { root: '날짜 선택', previous: '이전', next: '다음', year: '연도', month: '월' }, en: { root: 'Select date', previous: 'Previous', next: 'Next', year: 'Year', month: 'Month' },} as const;/* ------------------------------------------------------------------------------------------------- * DatePicker * -----------------------------------------------------------------------------------------------*/function DatePicker(props: DatePickerProps) { const { selectionMode = 'single', value: valueProp, defaultValue, onValueChange, rangeStartReadOnly = false, visibleRange = 'month', locale = 'ko-KR', weekStartsOn: weekStartsOnProp, today: todayProp, yearRange: yearRangeProp, monthRange: monthRangeProp, constraints = [], viewDate: viewDateProp, defaultViewDate, onViewDateChange, disabled = false, readOnly = false, renderDateCellSupplement, ariaLabels: ariaLabelsProp, className, ...rootProps } = props as DatePickerBaseProps & { selectionMode?: DatePickerSelectionMode; value?: AnyValue; defaultValue?: AnyValue; onValueChange?: (value: never) => void; rangeStartReadOnly?: boolean; }; const today = React.useMemo(() => todayProp ?? getToday(), [todayProp]); const weekStartsOn = weekStartsOnProp ?? getWeekStartsOn(locale); const yearRange = yearRangeProp ?? { start: today.year - 100, end: today.year + 100 }; const language = (() => { try { return new Intl.Locale(locale).language; } catch { return 'ko'; } })(); const ariaLabels = { ...(language === 'ko' ? DEFAULT_ARIA_LABELS.ko : DEFAULT_ARIA_LABELS.en), ...ariaLabelsProp }; /* ---------- 선택 값 ---------- */ const [uncontrolledValue, setUncontrolledValue] = React.useState<AnyValue>(defaultValue ?? null); const isControlled = valueProp !== undefined; const value = isControlled ? valueProp : uncontrolledValue; const single = selectionMode === 'single' ? ((value as DatePickerDate | null) ?? null) : null; const range = selectionMode === 'range' ? ((value as DatePickerRangeValue | null) ?? null) : null; const multiple = selectionMode === 'multiple' ? ((value as readonly DatePickerDate[] | null) ?? []) : []; const selectedDates: DatePickerDate[] = single ? [single] : range ? [range.start, ...(range.end ? [range.end] : [])] : [...multiple]; const rangeComplete = Boolean(range?.end); const pendingRangeStart = range && (!range.end || rangeStartReadOnly) ? range.start : null; const commit = (next: AnyValue) => { if (!isControlled) setUncontrolledValue(next); (onValueChange as ((v: AnyValue) => void) | undefined)?.(next); }; /* ---------- 경계 ---------- */ const minMonth: DatePickerMonth = visibleRange === 'continuous' && monthRangeProp ? monthRangeProp.start : { year: yearRange.start, month: 1 }; const maxMonth: DatePickerMonth = visibleRange === 'continuous' && monthRangeProp ? monthRangeProp.end : { year: yearRange.end, month: 12 }; const minDate: DatePickerDate = { ...minMonth, day: 1 }; const maxDate: DatePickerDate = { ...maxMonth, day: daysInMonth(maxMonth.year, maxMonth.month) }; const clampDate = (d: DatePickerDate) => compareDates(d, minDate) < 0 ? minDate : compareDates(d, maxDate) > 0 ? maxDate : d; /* ---------- 표시 기준 날짜 ---------- */ const normalizeView = React.useCallback( (d: DatePickerDate): DatePickerDate => visibleRange === 'week' ? startOfWeek(d, weekStartsOn) : { year: d.year, month: d.month, day: 1 }, [visibleRange, weekStartsOn], ); const initialAnchor = (): DatePickerDate => { if (defaultViewDate) return defaultViewDate; const initial = (defaultValue ?? valueProp) as AnyValue; if (initial && 'start' in initial) return initial.start; if (Array.isArray(initial) && initial.length > 0) return [...initial].sort(compareDates)[0] as DatePickerDate; if (initial && 'year' in initial) return initial; return today; }; const [uncontrolledView, setUncontrolledView] = React.useState<DatePickerDate>(() => normalizeView(initialAnchor())); const view = viewDateProp ? normalizeView(viewDateProp) : uncontrolledView; const viewMonth: DatePickerMonth = { year: view.year, month: view.month }; const setView = (d: DatePickerDate) => { const next = normalizeView(clampDate(d)); if (isSameDate(next, view)) return; if (!viewDateProp) setUncontrolledView(next); onViewDateChange?.(next); }; /* ---------- 포커스 (roving tabindex) ---------- */ const [focusedDate, setFocusedDate] = React.useState<DatePickerDate>(() => { const anchor = initialAnchor(); return anchor; }); const shouldFocusRef = React.useRef(false); const rootRef = React.useRef<HTMLDivElement>(null); const visibleMonths: DatePickerMonth[] = React.useMemo(() => { if (visibleRange === 'twoMonths') return [viewMonth, addMonths(viewMonth, 1)]; if (visibleRange === 'continuous') { const start = monthRangeProp?.start ?? viewMonth; const end = monthRangeProp?.end ?? addMonths(start, 11); const count = Math.max(1, monthIndex(end) - monthIndex(start) + 1); return Array.from({ length: count }, (_, i) => addMonths(start, i)); } return [viewMonth]; // eslint-disable-next-line react-hooks/exhaustive-deps }, [visibleRange, view.year, view.month, monthRangeProp?.start.year, monthRangeProp?.start.month, monthRangeProp?.end.year, monthRangeProp?.end.month]); const isVisible = (d: DatePickerDate) => { if (visibleRange === 'week') return dayDiff(view, d) >= 0 && dayDiff(view, d) < 7; return visibleMonths.some((m) => m.year === d.year && m.month === d.month); }; // 포커스할 날짜가 화면에 없으면 선택일 → 오늘 → 첫 날 순으로 대체해요. const tabbableDate = (() => { if (isVisible(focusedDate)) return focusedDate; const candidate = selectedDates.find(isVisible) ?? (isVisible(today) ? today : null); if (candidate) return candidate; if (visibleRange === 'week') return view; const first = visibleMonths[0]!; return { ...first, day: 1 }; })(); const moveFocus = (target: DatePickerDate) => { const next = clampDate(target); setFocusedDate(next); shouldFocusRef.current = true; if (isVisible(next)) return; if (visibleRange === 'twoMonths') { setView(compareDates(next, view) < 0 ? next : { ...addMonths({ year: next.year, month: next.month }, -1), day: 1 }); } else if (visibleRange !== 'continuous') { setView(next); } }; React.useEffect(() => { if (!shouldFocusRef.current) return; shouldFocusRef.current = false; const key = `${tabbableDate.year}-${tabbableDate.month}-${tabbableDate.day}`; const button = rootRef.current?.querySelector<HTMLButtonElement>(`[data-date="${key}"]`); button?.focus(); if (visibleRange === 'continuous') button?.scrollIntoView({ block: 'nearest' }); }); /* ---------- 날짜 상태 ---------- */ const constraintContext: DatePickerConstraintContext = { selectionMode, rangeStart: pendingRangeStart, selectedDates, }; const isUnavailable = (d: DatePickerDate) => { if (compareDates(d, minDate) < 0 || compareDates(d, maxDate) > 0) return true; if (rangeStartReadOnly && range && compareDates(d, range.start) < 0) return true; return !constraints.every((constraint) => constraint(d, constraintContext)); }; const select = (d: DatePickerDate) => { if (disabled || readOnly) return; setFocusedDate(d); if (selectionMode === 'single') { if (isUnavailable(d)) return; if (!isSameDate(single, d)) commit(d); return; } if (selectionMode === 'multiple') { if (isUnavailable(d)) return; const exists = multiple.some((m) => isSameDate(m, d)); const next = exists ? multiple.filter((m) => !isSameDate(m, d)) : [...multiple, d]; commit([...next].sort(compareDates)); return; } // range if (rangeStartReadOnly) { if (!range || compareDates(d, range.start) <= 0 || isUnavailable(d)) return; commit({ start: range.start, end: d }); return; } if (!range || range.end || compareDates(d, range.start) < 0) { // 새 시작일은 제약 조건 중 단일 날짜 조건만 검사해요. const fresh: DatePickerConstraintContext = { selectionMode, rangeStart: null, selectedDates: [] }; if (compareDates(d, minDate) < 0 || compareDates(d, maxDate) > 0) return; if (!constraints.every((constraint) => constraint(d, fresh))) return; commit({ start: d }); return; } if (isUnavailable(d)) return; commit({ start: range.start, end: d }); }; /* ---------- 포맷 ---------- */ const formatters = React.useMemo( () => ({ monthYear: new Intl.DateTimeFormat(locale, { year: 'numeric', month: 'long', timeZone: 'UTC' }), year: new Intl.DateTimeFormat(locale, { year: 'numeric', timeZone: 'UTC' }), month: new Intl.DateTimeFormat(locale, { month: 'long', timeZone: 'UTC' }), weekdayShort: new Intl.DateTimeFormat(locale, { weekday: 'short', timeZone: 'UTC' }), weekdayLong: new Intl.DateTimeFormat(locale, { weekday: 'long', timeZone: 'UTC' }), full: new Intl.DateTimeFormat(locale, { dateStyle: 'full', timeZone: 'UTC' }), day: new Intl.NumberFormat(locale, { useGrouping: false }), }), [locale], ); const formatMonth = (m: DatePickerMonth) => formatters.monthYear.format(new Date(toTime({ ...m, day: 1 }))); // 2026-02-01 은 일요일이에요. 주 시작일부터 7개 요일 이름을 구해요. const weekdays = React.useMemo( () => Array.from({ length: 7 }, (_, i) => { const date = new Date(Date.UTC(2026, 1, 1 + mod(weekStartsOn + i, 7))); return { short: formatters.weekdayShort.format(date), long: formatters.weekdayLong.format(date) }; }), [formatters, weekStartsOn], ); /* ---------- 키보드 ---------- */ const handleGridKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => { const target = event.target as HTMLElement; if (!target.dataset.date) return; const current = tabbableDate; const rtl = getComputedStyle(event.currentTarget).direction === 'rtl'; const unit = visibleRange === 'week' ? 'week' : 'month'; let next: DatePickerDate | null = null; switch (event.key) { case 'ArrowLeft': next = addDays(current, rtl ? 1 : -1); break; case 'ArrowRight': next = addDays(current, rtl ? -1 : 1); break; case 'ArrowUp': next = addDays(current, -7); break; case 'ArrowDown': next = addDays(current, 7); break; case 'Home': next = startOfWeek(current, weekStartsOn); break; case 'End': next = addDays(startOfWeek(current, weekStartsOn), 6); break; case 'PageUp': case 'PageDown': { const dir = event.key === 'PageUp' ? -1 : 1; if (event.shiftKey) { const m = addMonths(current, dir * 12); next = { ...m, day: Math.min(current.day, daysInMonth(m.year, m.month)) }; } else if (unit === 'week') { next = addDays(current, dir * 7); } else { const m = addMonths(current, dir); next = { ...m, day: Math.min(current.day, daysInMonth(m.year, m.month)) }; } break; } } if (next) { event.preventDefault(); moveFocus(next); } }; /* ---------- 월·연도 휠 ---------- */ const [wheelOpen, setWheelOpen] = React.useState(false); const [wheelMonth, setWheelMonth] = React.useState<DatePickerMonth>(viewMonth); const headerMonth: DatePickerMonth = (() => { if (visibleRange !== 'week') return viewMonth; // Week 가 두 달에 걸치면 다음 달을 표시해요. 다음 달이 범위 밖이면 이전 달이에요. const last = addDays(view, 6); const lastMonth = { year: last.year, month: last.month }; return monthIndex(lastMonth) > monthIndex(maxMonth) ? viewMonth : lastMonth; })(); const yearOptions = React.useMemo( () => Array.from({ length: yearRange.end - yearRange.start + 1 }, (_, i) => { const year = yearRange.start + i; return { value: String(year), label: formatters.year.format(new Date(Date.UTC(year, 0, 1))) }; }), [yearRange.start, yearRange.end, formatters], ); const monthOptions = React.useMemo( () => Array.from({ length: 12 }, (_, i) => ({ value: String(i + 1), label: formatters.month.format(new Date(Date.UTC(2026, i, 1))), })), [formatters], ); const wheelValue = visibleRange === 'week' ? wheelMonth : viewMonth; const applyWheel = (m: DatePickerMonth) => { const clamped = fromMonthIndex(Math.min(monthIndex(maxMonth), Math.max(monthIndex(minMonth), monthIndex(m)))); if (visibleRange === 'week') setWheelMonth(clamped); else setView({ ...clamped, day: 1 }); }; const wheelColumns: WheelPickerColumn[] = [ { id: 'year', 'aria-label': ariaLabels.year, options: yearOptions, value: String(wheelValue.year), itemAlign: 'end', className: 'w-[120px] min-w-[120px] flex-[0_0_120px]', onValueChange: (year) => applyWheel({ year: Number(year), month: wheelValue.month }), }, { id: 'month', 'aria-label': ariaLabels.month, options: monthOptions, value: String(wheelValue.month), loop: true, itemAlign: 'start', className: 'w-[96px] min-w-[96px] flex-[0_0_96px]', onValueChange: (month) => applyWheel({ year: wheelValue.year, month: Number(month) }), }, ]; const handleWeekWheelOpenChange = (open: boolean) => { if (open) { setWheelMonth(headerMonth); } else if (monthIndex(wheelMonth) !== monthIndex(headerMonth)) { setView({ ...wheelMonth, day: 1 }); } setWheelOpen(open); }; /* ---------- 렌더 조각 ---------- */ const canGoPrev = visibleRange === 'week' ? compareDates(view, minDate) > 0 : monthIndex(viewMonth) > monthIndex(minMonth); const lastVisible = visibleRange === 'twoMonths' ? addMonths(viewMonth, 1) : viewMonth; const canGoNext = visibleRange === 'week' ? compareDates(addDays(view, 6), maxDate) < 0 : monthIndex(lastVisible) < monthIndex(maxMonth); const go = (dir: -1 | 1) => { if (visibleRange === 'week') setView(addDays(view, dir * 7)); else setView({ ...addMonths(viewMonth, dir), day: 1 }); }; const navButton = (dir: -1 | 1, extraClassName?: string) => ( <ActionButton type="button" variant="ghost" size="medium" layout="iconOnly" data-slot="date-picker-navigation-button" aria-label={dir === -1 ? ariaLabels.previous : ariaLabels.next} disabled={disabled || (dir === -1 ? !canGoPrev : !canGoNext)} onClick={() => go(dir)} className={cn('disabled:bg-transparent', extraClassName)} > {dir === -1 ? <ChevronLeftIcon className="rtl:rotate-180" /> : <ChevronRightIcon className="rtl:rotate-180" />} </ActionButton> ); const headerLabelButton = ( <button type="button" data-slot="date-picker-header-label" aria-expanded={wheelOpen} disabled={disabled} onClick={visibleRange === 'week' ? undefined : () => setWheelOpen((open) => !open)} className={cn( 'inline-flex h-full min-w-0 cursor-pointer items-center gap-x1 whitespace-nowrap rounded-r2 border-0 bg-transparent px-x1 py-0', 'text-t5 font-bold text-fg-neutral', 'outline-2 -outline-offset-2 outline-transparent transition-[outline-color] duration-(--rb-duration-color-transition) ease-easing focus-visible:outline-stroke-focus-ring', 'disabled:cursor-default', )} > <span className="min-w-0 truncate" aria-live="polite"> {formatMonth(headerMonth)} </span> <span data-slot="date-picker-header-chevron" data-expanded={wheelOpen || undefined} className="inline-flex size-x5 shrink-0 items-center justify-center transition-transform duration-(--rb-duration-d3) ease-easing data-expanded:rotate-180 [&_svg]:size-x5" > <ChevronDownIcon aria-hidden /> </span> </button> ); const weekdayRow = (className?: string) => ( <div role="row" data-slot="date-picker-weekday-row" className={cn('relative z-[2] grid grid-cols-7 bg-bg-layer-floating', className)} > {weekdays.map((weekday, i) => ( <div key={i} role="columnheader" aria-label={weekday.long} data-slot="date-picker-weekday" className={cn( 'flex items-center justify-center text-t4 font-medium text-fg-neutral-subtle', visibleRange === 'week' ? 'h-[24px]' : 'h-x12', )} > {weekday.short} </div> ))} </div> ); const renderCell = (d: DatePickerDate | null, key: React.Key) => { if (!d) return <div key={key} role="gridcell" data-slot="date-picker-empty-cell" className="min-h-x12" />; const isToday = isSameDate(d, today); const isRangeStart = isSameDate(range?.start, d); const isRangeEnd = isSameDate(range?.end, d); const inRange = Boolean( range?.end && compareDates(d, range.start) > 0 && compareDates(d, range.end) < 0, ); const selected = selectionMode === 'single' ? isSameDate(single, d) : selectionMode === 'multiple' ? multiple.some((m) => isSameDate(m, d)) : isRangeStart || isRangeEnd; const readOnlyStart = rangeStartReadOnly && isRangeStart; const unavailable = isUnavailable(d) && !selected; const focused = isSameDate(tabbableDate, d); const interactive = !disabled && !readOnly && !readOnlyStart && !unavailable; const formattedDay = formatters.day.format(d.day); const cellProps: DatePickerCellRenderProps = { date: d, formattedDay, selected, today: isToday, inRange, rangeStart: isRangeStart, rangeEnd: isRangeEnd, unavailable, focused, }; const showBand = rangeComplete && !isSameDate(range?.start, range?.end) && (inRange || isRangeStart || isRangeEnd); const visualClassName = readOnlyStart ? 'bg-palette-gray-600' : selected ? 'bg-bg-neutral-solid' : inRange ? 'bg-transparent' : isToday ? 'bg-bg-neutral-weak' : 'bg-bg-transparent'; return ( <div key={key} role="gridcell" aria-selected={selected || inRange || undefined} data-slot="date-picker-date-cell" data-in-range={inRange || undefined} data-range-start={isRangeStart || undefined} data-range-end={isRangeEnd || undefined} data-range-complete={rangeComplete || undefined} className="relative isolate min-h-x12 min-w-0" > {showBand && ( <span aria-hidden data-slot="date-picker-range-background" className={cn( 'pointer-events-none absolute top-[3px] -z-10 h-[42px] bg-bg-neutral-weak', inRange && 'inset-x-0', isRangeStart && 'start-1/2 end-0', isRangeEnd && 'start-0 end-1/2', )} /> )} <button type="button" data-slot="date-picker-date-button" data-date={`${d.year}-${d.month}-${d.day}`} data-today={isToday || undefined} data-selected={selected || undefined} data-in-range={inRange || undefined} data-unavailable={unavailable || undefined} data-range-start-readonly={readOnlyStart || undefined} tabIndex={focused ? 0 : -1} disabled={disabled} aria-label={formatters.full.format(new Date(toTime(d)))} aria-current={isToday ? 'date' : undefined} aria-disabled={unavailable || readOnly || readOnlyStart || undefined} onClick={() => select(d)} onFocus={() => { if (!isSameDate(focusedDate, d)) setFocusedDate(d); }} className={cn( 'group/date relative isolate flex size-full min-h-x12 min-w-0 cursor-pointer items-start justify-center border-0 bg-transparent p-0 font-[inherit] outline-none', 'text-fg-neutral-muted data-selected:text-fg-on-neutral-solid', 'data-unavailable:cursor-not-allowed data-unavailable:text-fg-disabled', 'data-range-start-readonly:cursor-default disabled:cursor-default disabled:text-fg-disabled', readOnly && 'cursor-default', )} > <span aria-hidden data-slot="date-picker-date-visual" className={cn( 'absolute top-[3px] left-1/2 -z-10 size-[42px] -translate-x-1/2 rounded-full', 'transition-[background-color] duration-(--rb-duration-color-transition) ease-easing', visualClassName, interactive && !selected && 'group-active/date:bg-bg-transparent-pressed', )} /> <span aria-hidden data-slot="date-picker-focus-ring" className={cn( 'pointer-events-none absolute top-[3px] left-1/2 z-[2] size-[42px] -translate-x-1/2 rounded-full', 'outline-2 -outline-offset-2 outline-transparent transition-[outline-color] duration-(--rb-duration-color-transition) ease-easing', 'group-focus-visible/date:outline-stroke-focus-ring', )} /> <span data-slot="date-picker-date-content" className="relative z-[1] box-border flex min-h-x12 w-full min-w-0 flex-col items-center gap-[3px] pt-[3px] text-t5 font-medium tabular-nums" > <span data-date-picker-day className={cn( 'flex min-h-[42px] shrink-0 items-center justify-center', unavailable && !(isRangeStart && !rangeComplete) && 'line-through', )} > {formattedDay} </span> {renderDateCellSupplement?.(cellProps)} </span> </button> </div> ); }; const monthWeeks = (m: DatePickerMonth): (DatePickerDate | null)[][] => { const first: DatePickerDate = { ...m, day: 1 }; const offset = mod(dayOfWeek(first) - weekStartsOn, 7); const count = daysInMonth(m.year, m.month); const cells: (DatePickerDate | null)[] = [ ...Array.from({ length: offset }, () => null), ...Array.from({ length: count }, (_, i) => ({ ...m, day: i + 1 })), ]; while (cells.length % 7 !== 0) cells.push(null); return Array.from({ length: cells.length / 7 }, (_, i) => cells.slice(i * 7, i * 7 + 7)); }; const renderGrid = (weeks: (DatePickerDate | null)[][], label: string) => ( <div role="rowgroup" data-slot="date-picker-grid" aria-label={label} className="grid min-w-0"> {weeks.map((week, w) => ( <div key={w} role="row" data-slot="date-picker-week-row" className="grid grid-cols-7"> {week.map((d, i) => renderCell(d, i))} </div> ))} </div> ); /* ---------- 레이아웃 ---------- */ let body: React.ReactNode; if (visibleRange === 'continuous') { body = ( <ContinuousScroll months={visibleMonths} initialMonth={viewMonth} weekdayRow={weekdayRow('sticky top-0')} renderMonth={(m) => ( <div key={monthIndex(m)} data-slot="date-picker-month" data-month={monthIndex(m)} className="min-w-0 pb-[16px]"> <div data-slot="date-picker-month-label" className="flex h-x12 items-center text-t5 font-bold text-fg-neutral" > {formatMonth(m)} </div> {renderGrid(monthWeeks(m), formatMonth(m))} </div> )} onKeyDown={handleGridKeyDown} /> ); } else if (visibleRange === 'twoMonths') { body = ( <div data-slot="date-picker-months" className="grid grid-cols-2 items-start gap-x-x6 min-w-0" onKeyDown={handleGridKeyDown} > {visibleMonths.map((m, index) => ( <div key={monthIndex(m)} role="grid" aria-label={formatMonth(m)} data-slot="date-picker-month" className="min-w-0"> <div data-slot="date-picker-two-month-header" className="relative flex h-x12 items-center justify-center"> <div className="flex h-full w-full items-center justify-center whitespace-nowrap text-t5 font-bold text-fg-neutral"> {formatMonth(m)} </div> {index === 0 && navButton(-1, 'absolute top-[4px] start-0')} {index === 1 && navButton(1, 'absolute top-[4px] end-0')} </div> {weekdayRow()} {renderGrid(monthWeeks(m), formatMonth(m))} </div> ))} </div> ); } else if (visibleRange === 'week') { const days = Array.from({ length: 7 }, (_, i) => addDays(view, i)); body = ( <> <div data-slot="date-picker-header" className="flex h-x12 items-center justify-between"> <PopoverPrimitive.Root open={wheelOpen} onOpenChange={handleWeekWheelOpenChange}> <PopoverPrimitive.Trigger asChild>{headerLabelButton}</PopoverPrimitive.Trigger> <PopoverPrimitive.Portal> <PopoverPrimitive.Content data-slot="date-picker-wheel-popover" align="start" sideOffset={4} collisionPadding={16} className={cn( 'z-[99999] w-[240px] overflow-hidden rounded-r3 bg-bg-layer-floating shadow-s3 outline-none', 'data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=open]:duration-(--rb-duration-d3) data-[state=open]:ease-enter', 'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=closed]:duration-(--rb-duration-d2) data-[state=closed]:ease-exit', )} > <WheelPicker size="small" aria-label={`${ariaLabels.year}, ${ariaLabels.month}`} columns={wheelColumns} className="[&_[data-slot=wheel-picker-selection-indicator]]:inset-x-0" /> </PopoverPrimitive.Content> </PopoverPrimitive.Portal> </PopoverPrimitive.Root> <div data-slot="date-picker-navigation" className="flex shrink-0 items-center"> {navButton(-1)} {navButton(1)} </div> </div> <div role="grid" aria-label={formatMonth(headerMonth)} onKeyDown={handleGridKeyDown}> {weekdayRow()} {renderGrid([days], formatMonth(headerMonth))} </div> </> ); } else { body = ( <> <div data-slot="date-picker-header" className="flex h-x12 items-center justify-between"> {headerLabelButton} <div data-slot="date-picker-navigation" className="flex shrink-0 items-center"> {navButton(-1)} {navButton(1)} </div> </div> <div role="grid" aria-label={formatMonth(viewMonth)} data-slot="date-picker-months" data-wheel-open={wheelOpen || undefined} aria-hidden={wheelOpen || undefined} className="grid min-w-0 grid-cols-1 items-start data-wheel-open:invisible" onKeyDown={handleGridKeyDown} > {weekdayRow()} {renderGrid(monthWeeks(viewMonth), formatMonth(viewMonth))} </div> {wheelOpen && ( <div data-slot="date-picker-wheel-container" className="absolute inset-x-0 top-x12 bottom-0 z-[1] flex items-center overflow-hidden bg-bg-layer-floating" > <WheelPicker aria-label={`${ariaLabels.year}, ${ariaLabels.month}`} columns={wheelColumns} /> </div> )} </> ); } return ( <div ref={rootRef} role="group" aria-label={rootProps['aria-labelledby'] ? undefined : ariaLabels.root} data-slot="date-picker" data-visible-range={visibleRange} data-selection-mode={selectionMode} data-disabled={disabled || undefined} data-readonly={readOnly || undefined} className={cn( 'relative box-border w-full min-w-0 bg-bg-layer-floating text-fg-neutral-muted', visibleRange === 'continuous' && 'flex flex-col overflow-hidden', className, )} {...rootProps} > {body} </div> );}/* ------------------------------------------------------------------------------------------------- * Continuous: 여러 달을 세로로 이어 붙여 스크롤로 탐색해요. * -----------------------------------------------------------------------------------------------*/function ContinuousScroll({ months, initialMonth, weekdayRow, renderMonth, onKeyDown,}: { months: DatePickerMonth[]; initialMonth: DatePickerMonth; weekdayRow: React.ReactNode; renderMonth: (m: DatePickerMonth) => React.ReactNode; onKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => void;}) { const ref = React.useRef<HTMLDivElement>(null); React.useLayoutEffect(() => { const el = ref.current; if (!el) return; const target = el.querySelector<HTMLElement>(`[data-month="${monthIndex(initialMonth)}"]`); const header = el.querySelector<HTMLElement>('[data-slot="date-picker-weekday-row"]'); if (target) el.scrollTop = target.offsetTop - (header?.offsetHeight ?? 0); // 처음 한 번만 표시 기준 월로 이동해요. // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return ( <div ref={ref} role="grid" data-slot="date-picker-continuous-scroll" onKeyDown={onKeyDown} className="relative h-full min-h-0 flex-1 overflow-y-auto overscroll-contain [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" > {weekdayRow} <div data-slot="date-picker-continuous-content" className="relative min-w-0"> {months.map(renderMonth)} </div> <div aria-hidden data-slot="date-picker-continuous-fog" className="pointer-events-none sticky bottom-0 z-[3] -mt-[96px] block h-[96px] w-full bg-linear-to-b from-transparent to-bg-layer-floating" /> </div> );}/* ------------------------------------------------------------------------------------------------- * 레이아웃 별칭 (SEED 공개 컴포넌트 이름) * -----------------------------------------------------------------------------------------------*/type DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never;export type LayoutDatePickerProps = DistributiveOmit<DatePickerProps, 'visibleRange'>;/** 연속한 두 달을 가로로 표시해요. */function TwoMonthDatePicker(props: LayoutDatePickerProps) { return <DatePicker {...(props as DatePickerProps)} visibleRange="twoMonths" />;}/** 한 주만 표시해요. 이전·다음 버튼은 한 주씩 이동해요. */function WeekDatePicker(props: LayoutDatePickerProps) { return <DatePicker {...(props as DatePickerProps)} visibleRange="week" />;}/** 여러 달을 세로로 이어 붙여 스크롤로 탐색해요. 높이(height, maxHeight 등)를 지정해요. */function ContinuousDatePicker(props: LayoutDatePickerProps) { return <DatePicker {...(props as DatePickerProps)} visibleRange="continuous" />;}/* ------------------------------------------------------------------------------------------------- * Popover Layout: 입력 필드에 anchor 된 패널로 캘린더를 띄워요. (radix-ui Popover) * -----------------------------------------------------------------------------------------------*/function DatePickerPopover(props: React.ComponentProps<typeof PopoverPrimitive.Root>) { return <PopoverPrimitive.Root data-slot="date-picker-popover" {...props} />;}function DatePickerPopoverTrigger(props: React.ComponentProps<typeof PopoverPrimitive.Trigger>) { return <PopoverPrimitive.Trigger data-slot="date-picker-popover-trigger" {...props} />;}function DatePickerPopoverClose(props: React.ComponentProps<typeof PopoverPrimitive.Close>) { return <PopoverPrimitive.Close data-slot="date-picker-popover-close" {...props} />;}export interface DatePickerPopoverContentProps extends React.ComponentProps<typeof PopoverPrimitive.Content> { /** 포털을 렌더링할 컨테이너예요. */ portalContainer?: React.ComponentProps<typeof PopoverPrimitive.Portal>['container'];}function DatePickerPopoverContent({ className, align = 'start', sideOffset = 8, collisionPadding = 16, portalContainer, ...props}: DatePickerPopoverContentProps) { return ( <PopoverPrimitive.Portal container={portalContainer}> <PopoverPrimitive.Content data-slot="date-picker-popover-content" align={align} sideOffset={sideOffset} collisionPadding={collisionPadding} className={cn( 'z-[99999] box-border flex w-[min(358px,var(--radix-popover-content-available-width,358px))] flex-col gap-x4 rounded-r5 bg-bg-layer-floating p-x4 shadow-s3 outline-none', 'origin-(--radix-popover-content-transform-origin)', 'data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=open]:duration-(--rb-duration-d3) data-[state=open]:ease-enter', 'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=closed]:duration-(--rb-duration-d2) data-[state=closed]:ease-exit', className, )} {...props} /> </PopoverPrimitive.Portal> );}export { DatePicker, TwoMonthDatePicker, WeekDatePicker, ContinuousDatePicker, DatePickerPopover, DatePickerPopoverTrigger, DatePickerPopoverContent, DatePickerPopoverClose,};