/** * @title Calendar * @description 한 달 단위로 날짜를 보여주고 날짜를 고를 수 있게 하는 달력이에요. */import * as React from 'react';import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react';import { cn } from '@/lib/utils';import { IconButton } from '@/components/ui/button';// Atlassian @atlaskit/calendar 19.x 스펙// - 컨테이너: padding space.200, 세로 간격 space.150, user-select none// - 헤더: paddingInline space.100, 이전 연도·이전 달 | 제목(heading xsmall) | 다음 달·다음 연도 (subtle compact IconButton)// - 요일 헤더: min-width 2.5rem, padding space.100, text small bold, color.text.subtle// - 날짜: radius.medium, font.body, border 2px transparent, padding 4px 9px// hover neutral-subtle-hovered / active neutral-subtle-pressed / focus-visible border.focused// sibling text.subtlest · today text.selected + bold + 하단 2px 밑줄 · previouslySelected background.selected + text.subtle// selected(platform-dst-tokens-finesse ON) background.selected.bold + text.inverse// (hover selected.bold.hovered / active selected.bold.pressed) · disabled text.disabled + not-allowed/* ------------------------------------------------------------------------------------------------- * 로캘 — Intl 출력은 Node 와 브라우저가 달라 하이드레이션이 깨질 수 있어서 고정 문자열을 쓴다. * -----------------------------------------------------------------------------------------------*/export type WeekDay = 0 | 1 | 2 | 3 | 4 | 5 | 6;export type TabIndex = -1 | 0;export interface CalendarLocale { /** 1월~12월 이름 */ monthsLong: string[]; /** 일요일부터 시작하는 짧은 요일 이름 */ daysShort: string[]; /** 일요일부터 시작하는 긴 요일 이름 */ daysLong: string[]; /** 로캘 기본 주 시작 요일 */ weekStartDay: WeekDay; /** 헤더 제목 (예: 2026년 10월) */ formatHeading: (year: number, month: number) => string; /** 날짜 버튼의 접근성 이름 */ formatDayLabel: (year: number, month: number, day: number, dayLong: string) => string; labels: { previousMonth: string; nextMonth: string; previousYear: string; nextYear: string; calendar: string };}const ko: CalendarLocale = { monthsLong: Array.from({ length: 12 }, (_, i) => `${i + 1}월`), daysShort: ['일', '월', '화', '수', '목', '금', '토'], daysLong: ['일요일', '월요일', '화요일', '수요일', '목요일', '금요일', '토요일'], weekStartDay: 0, formatHeading: (y, m) => `${y}년 ${m}월`, formatDayLabel: (y, m, d, dayLong) => `${y}년 ${m}월 ${d}일 ${dayLong}`, labels: { previousMonth: '이전 달', nextMonth: '다음 달', previousYear: '이전 연도', nextYear: '다음 연도', calendar: '달력' },};const enMonths = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December',];const en: CalendarLocale = { monthsLong: enMonths, daysShort: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'], daysLong: ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'], weekStartDay: 0, formatHeading: (y, m) => `${enMonths[m - 1]} ${y}`, formatDayLabel: (y, m, d, dayLong) => `${d}, ${dayLong} ${enMonths[m - 1]} ${y}`, labels: { previousMonth: 'Previous month', nextMonth: 'Next month', previousYear: 'Previous year', nextYear: 'Next year', calendar: 'calendar' },};const enGB: CalendarLocale = { ...en, weekStartDay: 1 };const ja: CalendarLocale = { monthsLong: Array.from({ length: 12 }, (_, i) => `${i + 1}月`), daysShort: ['日', '月', '火', '水', '木', '金', '土'], daysLong: ['日曜日', '月曜日', '火曜日', '水曜日', '木曜日', '金曜日', '土曜日'], weekStartDay: 0, formatHeading: (y, m) => `${y}年${m}月`, formatDayLabel: (y, m, d, dayLong) => `${y}年${m}月${d}日 ${dayLong}`, labels: { previousMonth: '前の月', nextMonth: '次の月', previousYear: '前の年', nextYear: '次の年', calendar: 'カレンダー' },};/** BCP 47 태그로 고정 로캘 데이터를 찾는다. ko / ja / en(-GB 는 월요일 시작) 을 지원하고 그 외는 en-US. */export function getCalendarLocale(locale = 'ko-KR'): CalendarLocale { const tag = locale.toLowerCase(); if (tag.startsWith('ko')) return ko; if (tag.startsWith('ja')) return ja; if (tag === 'en-gb' || tag === 'en-au' || tag === 'en-nz' || tag === 'en-ie') return enGB; return en;}/* ------------------------------------------------------------------------------------------------- * 날짜 유틸 (모두 UTC 로 계산해 시간대 영향을 없앤다) * -----------------------------------------------------------------------------------------------*/export interface DateObj { day: number; month: number; year: number;}const pad = (n: number) => String(n).padStart(2, '0');/** { year, month(1~12), day } → 'YYYY-MM-DD' */export function dateToString({ year, month, day }: DateObj): string { return `${String(year).padStart(4, '0')}-${pad(month)}-${pad(day)}`;}/** 'YYYY-MM-DD' → DateObj (형식이 틀리면 null) */export function parseIsoDate(iso: string | undefined | null): DateObj | null { if (!iso) return null; const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso); if (!m) return null; const year = Number(m[1]); const month = Number(m[2]); const day = Number(m[3]); if (month < 1 || month > 12 || day < 1 || day > getDaysInMonth(year, month)) return null; return { year, month, day };}/** month 는 1~12 */export function getDaysInMonth(year: number, month: number): number { return new Date(Date.UTC(year, month, 0)).getUTCDate();}/** 날짜에 n 일을 더한다 */export function addDays(date: DateObj, amount: number): DateObj { const d = new Date(Date.UTC(date.year, date.month - 1, date.day + amount)); return { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate() };}function shiftMonth({ year, month, day }: DateObj, amount: number): DateObj { const total = year * 12 + (month - 1) + amount; const y = Math.floor(total / 12); const m = (total % 12) + 1; return { year: y, month: m, day: Math.min(day, getDaysInMonth(y, m)) };}function getWeekDay(date: DateObj): number { return new Date(Date.UTC(date.year, date.month - 1, date.day)).getUTCDay();}/** 브라우저 로컬 시간 기준 오늘 'YYYY-MM-DD' */export function getTodayIso(): string { const now = new Date(); return dateToString({ year: now.getFullYear(), month: now.getMonth() + 1, day: now.getDate() });}/* ------------------------------------------------------------------------------------------------- * 제어/비제어 상태 * -----------------------------------------------------------------------------------------------*/function useControllable<T>(value: T | undefined, defaultValue: T): [T, (next: T) => void] { const [inner, setInner] = React.useState<T>(defaultValue); const isControlled = value !== undefined; const set = React.useCallback( (next: T) => { if (!isControlled) setInner(next); }, [isControlled], ); return [isControlled ? (value as T) : inner, set];}/* ------------------------------------------------------------------------------------------------- * Calendar * -----------------------------------------------------------------------------------------------*/export type ChangeEvent = { iso: string; type: 'left' | 'up' | 'right' | 'down' | 'prevMonth' | 'prevYear' | 'nextMonth' | 'nextYear';} & DateObj;export type SelectEvent = { iso: string } & DateObj;export interface CalendarProps extends Omit<React.ComponentProps<'div'>, 'onChange' | 'onSelect' | 'onFocus' | 'onBlur' | 'defaultValue' | 'tabIndex'> { /** 달력의 접근성 이름. 기본값은 로캘에 맞춰 '달력' */ label?: string; /** 포커스된 날짜(일). 0 이면 강조하지 않아요. */ day?: number; defaultDay?: number; /** 보여줄 달 (1~12) */ month?: number; defaultMonth?: number; /** 보여줄 연도 */ year?: number; defaultYear?: number; /** 선택된 날짜 'YYYY-MM-DD' 배열 */ selected?: string[]; defaultSelected?: string[]; /** 이전에 선택했던 날짜 'YYYY-MM-DD' 배열 */ previouslySelected?: string[]; defaultPreviouslySelected?: string[]; /** 비활성 날짜 'YYYY-MM-DD' 배열 */ disabled?: string[]; /** true 를 돌려주면 그 날짜를 비활성으로 만들어요. */ disabledDateFilter?: (date: string) => boolean; /** 선택 가능한 가장 이른 날짜 */ minDate?: string; /** 선택 가능한 가장 늦은 날짜 */ maxDate?: string; /** 오늘 날짜 'YYYY-MM-DD'. 지정하지 않으면 마운트 후 브라우저 시간으로 정해요. */ today?: string; /** BCP 47 언어 태그. 고정 문자열로 ko / ja / en 을 지원해요. */ locale?: string; /** 주 시작 요일 (0 일요일 ~ 6 토요일). 기본값은 로캘 기준 */ weekStartDay?: WeekDay; nextMonthLabel?: string; previousMonthLabel?: string; /** 달이 바뀌거나 키보드로 날짜를 옮길 때 호출돼요. */ onChange?: (event: ChangeEvent) => void; /** 날짜를 클릭하거나 Enter/Space 로 고를 때 호출돼요. */ onSelect?: (event: SelectEvent) => void; onFocus?: React.FocusEventHandler; onBlur?: React.FocusEventHandler; /** 마운트될 때 포커스된 날짜로 포커스를 옮겨요. */ shouldSetFocusOnCurrentDay?: boolean; /** 키보드 탭 순서에 포함할지 (0) 프로그램으로만 포커스할지 (-1) */ tabIndex?: TabIndex; testId?: string;}function Calendar({ label, day, defaultDay = 0, month, defaultMonth = 0, year, defaultYear = 0, selected, defaultSelected = [], previouslySelected, defaultPreviouslySelected = [], disabled, disabledDateFilter, minDate, maxDate, today, locale = 'ko-KR', weekStartDay, nextMonthLabel, previousMonthLabel, onChange, onSelect, onFocus, onBlur, shouldSetFocusOnCurrentDay = false, tabIndex = 0, testId, className, ref, ...props}: CalendarProps) { const loc = getCalendarLocale(locale); const startDay: WeekDay = weekStartDay ?? loc.weekStartDay; // 기본 연·월이 없으면 오늘 기준 (프리렌더 시점과 달이 다르면 클라이언트에서 다시 그려져요) const [fallback] = React.useState(() => { const now = new Date(); return { year: now.getFullYear(), month: now.getMonth() + 1 }; }); const [dayValue, setDayValue] = useControllable(day, defaultDay); const [monthValue, setMonthValue] = useControllable(month, defaultMonth || fallback.month); const [yearValue, setYearValue] = useControllable(year, defaultYear || fallback.year); const [selectedValue, setSelectedValue] = useControllable(selected, defaultSelected); const [previousValue, setPreviousValue] = useControllable(previouslySelected, defaultPreviouslySelected); // 오늘은 하이드레이션 후에 정한다 const [mountedToday, setMountedToday] = React.useState<string | undefined>(undefined); React.useEffect(() => { if (today === undefined) setMountedToday(getTodayIso()); }, [today]); const todayValue = today ?? mountedToday; const [shouldSetFocus, setShouldSetFocus] = React.useState(false); const headerId = React.useId(); const [hasInteracted, setHasInteracted] = React.useState(false); const minObj = parseIsoDate(minDate); const maxObj = parseIsoDate(maxDate); const minIso = minObj ? dateToString(minObj) : undefined; const maxIso = maxObj ? dateToString(maxObj) : undefined; const isDateDisabled = (iso: string) => (disabled ? disabled.includes(iso) : false) || (disabledDateFilter ? disabledDateFilter(iso) : false) || (minIso !== undefined && iso < minIso) || (maxIso !== undefined && iso > maxIso); // 로빙 탭 스톱: day → 선택된 날 → 오늘 → 1일 const daysInMonth = getDaysInMonth(yearValue, monthValue); const inThisMonth = (iso?: string) => { const d = parseIsoDate(iso); return d && d.year === yearValue && d.month === monthValue ? d.day : 0; }; const tabStopDay = dayValue > 0 && dayValue <= daysInMonth ? dayValue : inThisMonth(selectedValue[0]) || inThisMonth(todayValue) || 1; // 6주(42칸) 고정 그리드 const first: DateObj = { year: yearValue, month: monthValue, day: 1 }; const offset = (getWeekDay(first) - startDay + 7) % 7; const gridStart = addDays(first, -offset); const weeks: DateObj[][] = Array.from({ length: 6 }, (_, w) => Array.from({ length: 7 }, (_, d) => addDays(gridStart, w * 7 + d)), ); const daysShort = Array.from({ length: 7 }, (_, i) => loc.daysShort[(startDay + i) % 7]); const triggerChange = (next: DateObj, type: ChangeEvent['type']) => { onChange?.({ ...next, iso: dateToString(next), type }); setDayValue(next.day); setMonthValue(next.month); setYearValue(next.year); setShouldSetFocus(['up', 'down', 'left', 'right'].includes(type)); }; const triggerSelect = (date: DateObj) => { const iso = dateToString(date); onSelect?.({ ...date, iso }); setPreviousValue(selectedValue); setSelectedValue([iso]); }; const current: DateObj = { year: yearValue, month: monthValue, day: tabStopDay }; const navigateHeader = (type: 'prevMonth' | 'prevYear' | 'nextMonth' | 'nextYear') => { setHasInteracted(true); const amount = { prevMonth: -1, nextMonth: 1, prevYear: -12, nextYear: 12 }[type]; const next = shiftMonth({ ...current, day: dayValue || 1 }, amount); triggerChange({ ...next, day: dayValue ? next.day : 0 }, type); }; const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => { const arrows: Record<string, [ChangeEvent['type'], number]> = { ArrowLeft: ['left', -1], ArrowRight: ['right', 1], ArrowUp: ['up', -7], ArrowDown: ['down', 7], }; if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); const iso = dateToString(current); if (!isDateDisabled(iso)) triggerSelect(current); } else if (arrows[e.key]) { e.preventDefault(); const [type, amount] = arrows[e.key]; triggerChange(addDays(current, amount), type); } }; const heading = loc.formatHeading(yearValue, monthValue); const headingOf = (d: DateObj) => loc.formatHeading(d.year, d.month); const labels = { prevYear: `${loc.labels.previousYear}, ${headingOf(shiftMonth(current, -12))}`, prevMonth: `${previousMonthLabel ?? loc.labels.previousMonth}, ${headingOf(shiftMonth(current, -1))}`, nextMonth: `${nextMonthLabel ?? loc.labels.nextMonth}, ${headingOf(shiftMonth(current, 1))}`, nextYear: `${loc.labels.nextYear}, ${headingOf(shiftMonth(current, 12))}`, }; const navButtonClass = '[&_svg]:size-3 text-ds-icon-subtle'; const navTabIndex = tabIndex; return ( <div ref={ref} role="group" aria-label={label ?? loc.labels.calendar} data-slot="calendar" data-testid={testId && `${testId}--container`} className={cn('inline-block', className)} onFocus={onFocus} onBlur={onBlur} {...props} > <div className="flex flex-col gap-ds-150 p-ds-200 select-none" data-testid={testId && `${testId}--calendar`} > {/* 헤더 */} <div data-slot="calendar-header" className="flex items-center justify-between px-ds-100"> <div className="flex items-start gap-ds-100"> <IconButton appearance="subtle" spacing="compact" tabIndex={navTabIndex} className={navButtonClass} icon={<ChevronsLeft />} label={labels.prevYear} data-testid={testId && `${testId}--previous-year`} onClick={() => navigateHeader('prevYear')} /> <IconButton appearance="subtle" spacing="compact" tabIndex={navTabIndex} className={navButtonClass} icon={<ChevronLeft />} label={labels.prevMonth} data-testid={testId && `${testId}--previous-month`} onClick={() => navigateHeader('prevMonth')} /> </div> <div aria-live={hasInteracted ? 'polite' : undefined} data-testid={testId && `${testId}--current-month-year--container`}> <h2 id={headerId} className="m-0 ds-font-heading-xsmall text-ds-text" data-testid={testId && `${testId}--current-month-year`} > {heading} </h2> </div> <div className="flex items-end gap-ds-100"> <IconButton appearance="subtle" spacing="compact" tabIndex={navTabIndex} className={navButtonClass} icon={<ChevronRight />} label={labels.nextMonth} data-testid={testId && `${testId}--next-month`} onClick={() => navigateHeader('nextMonth')} /> <IconButton appearance="subtle" spacing="compact" tabIndex={navTabIndex} className={navButtonClass} icon={<ChevronsRight />} label={labels.nextYear} data-testid={testId && `${testId}--next-year`} onClick={() => navigateHeader('nextYear')} /> </div> </div> {/* 날짜 그리드 */} <div role="grid" aria-labelledby={headerId} onKeyDown={handleKeyDown} data-testid={testId && `${testId}--calendar-dates`} > <div aria-hidden="true"> <div role="row" className="grid grid-cols-[repeat(7,minmax(max-content,1fr))]" data-testid={testId && `${testId}--column-headers`}> {daysShort.map((shortDay) => ( <div key={shortDay} role="columnheader" className="flex min-w-[2.5rem] justify-center p-ds-100 whitespace-nowrap" data-testid={testId && `${testId}--column-header`} > <span className="ds-font-body-small font-ds-bold text-center text-ds-text-subtle">{shortDay}</span> </div> ))} </div> </div> <div role="rowgroup" data-testid={testId && `${testId}--month`}> {weeks.map((week) => ( <div key={dateToString(week[0])} role="row" className="grid grid-cols-[repeat(7,minmax(max-content,1fr))]" data-testid={testId && `${testId}--week`} > {week.map((date) => { const iso = dateToString(date); const isSibling = date.month !== monthValue || date.year !== yearValue; const isDisabled = isDateDisabled(iso); return ( <CalendarDate key={iso} date={date} label={loc.formatDayLabel(date.year, date.month, date.day, loc.daysLong[getWeekDay(date)])} isDisabled={isDisabled} isFocused={!isSibling && date.day === dayValue} isTabStop={!isSibling && date.day === tabStopDay} isToday={todayValue === iso} isSelected={!isDisabled && selectedValue.includes(iso)} isPreviouslySelected={!isDisabled && previousValue.includes(iso)} isSibling={isSibling} shouldSetFocus={shouldSetFocus || shouldSetFocusOnCurrentDay} tabIndex={tabIndex} testId={testId} onClick={triggerSelect} /> ); })} </div> ))} </div> </div> </div> </div> );}interface CalendarDateProps { date: DateObj; label: string; isDisabled: boolean; isFocused: boolean; isTabStop: boolean; isToday: boolean; isSelected: boolean; isPreviouslySelected: boolean; isSibling: boolean; shouldSetFocus: boolean; tabIndex: TabIndex; testId?: string; onClick: (date: DateObj) => void;}function CalendarDate({ date, label, isDisabled, isFocused, isTabStop, isToday, isSelected, isPreviouslySelected, isSibling, shouldSetFocus, tabIndex, testId, onClick,}: CalendarDateProps) { const buttonRef = React.useRef<HTMLButtonElement>(null); React.useEffect(() => { if (isTabStop && shouldSetFocus) buttonRef.current?.focus(); }, [isTabStop, shouldSetFocus, date.month, date.year]); return ( <div role="gridcell" className="grid items-center"> <button ref={buttonRef} type="button" data-slot="calendar-date" aria-current={isToday ? 'date' : undefined} aria-disabled={isDisabled || undefined} aria-label={label} aria-pressed={isSelected ? 'true' : 'false'} tabIndex={isTabStop ? tabIndex : -1} data-disabled={isDisabled || undefined} data-focused={isFocused || undefined} data-prev-selected={isPreviouslySelected || undefined} data-selected={isSelected || undefined} data-sibling={isSibling || undefined} data-today={isToday || undefined} data-testid={testId && (isSelected ? `${testId}--selected-day` : `${testId}--day`)} onClick={() => { if (!isDisabled) onClick(date); }} className={cn( 'relative block grow cursor-pointer appearance-none rounded-ds-medium border-2 border-solid border-transparent bg-transparent px-[9px] py-ds-050 text-center ds-font-body text-ds-text outline-none', 'transition-[background-color,border-color,color] duration-[50ms] ease-[cubic-bezier(0.4,1,0.6,1)] active:duration-100 focus-visible:transition-none', 'hover:bg-ds-background-neutral-subtle-hovered active:bg-ds-background-neutral-subtle-pressed focus-visible:border-ds-border-focused', isSibling && 'text-ds-text-subtlest', isToday && "font-ds-bold text-ds-text-selected after:absolute after:inset-x-ds-025 after:bottom-ds-025 after:block after:h-[2px] after:bg-current after:content-['']", isPreviouslySelected && 'bg-ds-background-selected text-ds-text-subtle', isSelected && 'bg-ds-background-selected-bold text-ds-text-inverse hover:bg-ds-background-selected-bold-hovered hover:text-ds-text-inverse active:bg-ds-background-selected-bold-pressed active:text-ds-text-inverse', isDisabled && 'cursor-not-allowed text-ds-text-disabled hover:bg-transparent active:bg-transparent', (isSelected || isPreviouslySelected) && 'duration-100', )} > {date.day} </button> </div> );}export { Calendar };