/** * @title Datetime Picker * @description 날짜·시간을 직접 입력하거나 달력과 시간 목록에서 고를 수 있는 DatePicker, TimePicker, DateTimePicker 예요. */import * as React from 'react';import { Popover as PopoverPrimitive } from 'radix-ui';import { Calendar as CalendarIcon, CircleX, Clock } from 'lucide-react';import { cn } from '@/lib/utils';import { IconButton } from '@/components/ui/button';import { Calendar, dateToString, getCalendarLocale, getTodayIso, parseIsoDate, type DateObj, type WeekDay,} from '@/components/ui/calendar';// Atlassian @atlaskit/datetime-picker 스펙// - 컨트롤(date-time-picker-container / select control): radius.medium, border 1px border.input, background.input// hover background.input.hovered · 포커스 border.focused + 안쪽 1px box-shadow, background.input.pressed// 오류 border.danger + 안쪽 1px danger box-shadow · 비활성 border.disabled + background.disabled// appearance subtle 은 평소 테두리·배경 없음, none 은 항상 없음// - 높이: default 40px(gridSize*5), compact 32px(gridSize*4)// - 달력 메뉴: radius.large, surface.overlay, shadow.overlay, z-index 300// - 기본 시간 목록: 09:00 ~ 18:00, 30분 간격// - 플래그 ON(platform-dst-motion-uplift-input): 비활성이 아닐 때만 150ms --ds-input 전환,// DateTimePicker 는 포커스된 쪽(날짜/시간) 입력에 바깥 1px + 안쪽 1px focused 링을 덧그려// 바깥 테두리까지 2px 로 덮어요 (모서리는 radius.medium - border.width).export type Appearance = 'default' | 'subtle' | 'none';export type Spacing = 'compact' | 'default';export const defaultTimes = [ '09:00', '09:30', '10:00', '10:30', '11:00', '11:30', '12:00', '12:30', '13:00', '13:30', '14:00', '14:30', '15:00', '15:30', '16:00', '16:30', '17:00', '17:30', '18:00',];/* ------------------------------------------------------------------------------------------------- * 포맷 · 파싱 (Intl 대신 고정 규칙 — 서버와 브라우저 출력이 같아야 해요) * -----------------------------------------------------------------------------------------------*/type LocaleKind = 'ko' | 'ja' | 'en';const localeKind = (locale: string): LocaleKind => { const tag = locale.toLowerCase(); if (tag.startsWith('ko')) return 'ko'; if (tag.startsWith('ja')) return 'ja'; return 'en';};/** 로캘 기본 날짜 표기 (date-fns v1 토큰) */const defaultDateFormatOf = (locale: string) => ({ ko: 'YYYY. M. D.', ja: 'YYYY/M/D', en: 'M/D/YYYY' })[localeKind(locale)];/** 로캘 기본 시간 표기 */const defaultTimeFormatOf = (locale: string) => ({ ko: 'A h:mm', ja: 'H:mm', en: 'h:mm A' })[localeKind(locale)];const pad2 = (n: number) => String(n).padStart(2, '0');/** 'YYYY-MM-DD' 를 date-fns v1 토큰(YYYY, YY, MMMM, MM, M, DD, D, dddd, ddd) 으로 표기 */export function formatDate(iso: string, dateFormat: string, locale = 'ko-KR'): string { const d = parseIsoDate(iso); if (!d) return ''; const loc = getCalendarLocale(locale); const weekDay = new Date(Date.UTC(d.year, d.month - 1, d.day)).getUTCDay(); return dateFormat.replace(/YYYY|YY|MMMM|MM|M|DD|D|dddd|ddd/g, (token) => { switch (token) { case 'YYYY': return String(d.year); case 'YY': return String(d.year).slice(-2); case 'MMMM': return loc.monthsLong[d.month - 1]; case 'MM': return pad2(d.month); case 'M': return String(d.month); case 'DD': return pad2(d.day); case 'D': return String(d.day); case 'dddd': return loc.daysLong[weekDay]; case 'ddd': return loc.daysShort[weekDay]; default: return token; } });}/** 입력 문자열을 'YYYY-MM-DD' 로 해석. 숫자 세 묶음을 표기 순서(Y·M·D)대로 읽어요. */export function parseDate(text: string, dateFormat: string | undefined, locale = 'ko-KR'): string | null { const groups = text.match(/\d+/g); if (!groups || groups.length < 3) return null; let order: ('Y' | 'M' | 'D')[]; if (groups[0].length === 4) order = ['Y', 'M', 'D']; else if (dateFormat) { const pos = { Y: dateFormat.indexOf('Y'), M: dateFormat.indexOf('M'), D: dateFormat.indexOf('D') }; order = (['Y', 'M', 'D'] as const).filter((k) => pos[k] >= 0).sort((a, b) => pos[a] - pos[b]); if (order.length < 3) order = localeKind(locale) === 'en' ? ['M', 'D', 'Y'] : ['Y', 'M', 'D']; } else order = localeKind(locale) === 'en' ? ['M', 'D', 'Y'] : ['Y', 'M', 'D']; const parts: Record<string, number> = {}; order.forEach((k, i) => (parts[k] = Number(groups[i]))); let year = parts.Y; if (groups[order.indexOf('Y')].length <= 2) year += 2000; const iso = `${String(year).padStart(4, '0')}-${pad2(parts.M)}-${pad2(parts.D)}`; return parseIsoDate(iso) ? iso : null;}const meridiem = (kind: LocaleKind, hour: number, lower: boolean) => { const pm = hour >= 12; if (kind === 'ko') return pm ? '오후' : '오전'; if (kind === 'ja') return pm ? '午後' : '午前'; return lower ? (pm ? 'pm' : 'am') : pm ? 'PM' : 'AM';};/** 'HH:mm' 을 토큰(HH, H, hh, h, mm, A, a) 으로 표기 */export function formatTime(time: string, timeFormat: string, locale = 'ko-KR'): string { const m = /^(\d{1,2}):(\d{2})/.exec(time); if (!m) return time; const hour = Number(m[1]); const minute = Number(m[2]); const kind = localeKind(locale); const h12 = hour % 12 === 0 ? 12 : hour % 12; return timeFormat.replace(/HH|H|hh|h|mm|A|a/g, (token) => { switch (token) { case 'HH': return pad2(hour); case 'H': return String(hour); case 'hh': return pad2(h12); case 'h': return String(h12); case 'mm': return pad2(minute); case 'A': return meridiem(kind, hour, false); case 'a': return meridiem(kind, hour, true); default: return token; } });}/** '9', '930', '9:30pm', '오후 3:30', '15:30' 같은 입력을 'HH:mm' 으로 해석 */export function parseTime(text: string): string | null { const raw = text.trim().toLowerCase(); if (!raw) return null; const isPm = /p|오후|午後/.test(raw); const isAm = /a|오전|午前/.test(raw); const digits = raw.match(/\d+/g); if (!digits) return null; let hour: number; let minute = 0; if (digits.length >= 2) { hour = Number(digits[0]); minute = Number(digits[1]); } else { const s = digits[0]; if (s.length <= 2) hour = Number(s); else if (s.length <= 4) { hour = Number(s.slice(0, s.length - 2)); minute = Number(s.slice(-2)); } else return null; } if (isPm && hour < 12) hour += 12; if (isAm && hour === 12) hour = 0; if (hour > 23 || minute > 59) return null; return `${pad2(hour)}:${pad2(minute)}`;}const dateToLocalIso = (date: Date) => dateToString({ year: date.getFullYear(), month: date.getMonth() + 1, day: date.getDate() });/* ------------------------------------------------------------------------------------------------- * 공용 컨트롤 스타일 * -----------------------------------------------------------------------------------------------*/interface ControlState { appearance: Appearance; spacing: Spacing; isDisabled: boolean; isFocused: boolean; isInvalid: boolean;}export function pickerControlClass({ appearance, spacing, isDisabled, isFocused, isInvalid }: ControlState) { return cn( 'relative box-border flex w-full items-center rounded-ds-medium border border-solid border-ds-border-input bg-ds-background-input ds-font-body text-ds-text cursor-pointer', !isDisabled && 'transition-[background-color,border-color,box-shadow] duration-(--ds-motion-input-duration) ease-(--ds-motion-input-curve)', spacing === 'compact' ? 'min-h-8' : 'min-h-10', isDisabled && 'cursor-default border-ds-border-disabled bg-ds-background-disabled text-ds-text-disabled', isFocused && 'bg-ds-background-input-pressed', appearance === 'subtle' && !isFocused && 'border-transparent bg-transparent', isFocused && 'border-ds-border-focused shadow-[inset_0_0_0_1px_var(--ds-border-focused)]', isInvalid && 'border-ds-border-danger shadow-[inset_0_0_0_1px_var(--ds-border-danger)]', !(isFocused || isDisabled) && (isInvalid ? 'hover:border-ds-border-danger hover:bg-ds-background-input-hovered' : 'hover:border-ds-border-input hover:bg-ds-background-input-hovered'), appearance === 'none' && 'border-transparent bg-transparent shadow-none hover:border-transparent hover:bg-transparent', );}const inputClass = 'box-border h-full min-w-0 flex-1 border-0 bg-transparent py-ds-025 pl-ds-100 pr-ds-050 ds-font-body text-inherit outline-none cursor-[inherit] placeholder:text-ds-text-subtlest disabled:cursor-default disabled:placeholder:text-ds-text-disabled';const indicatorClass = 'flex min-h-6 min-w-6 shrink-0 items-center justify-center px-ds-075 text-ds-icon-subtle [&_svg]:size-4';function ClearButton({ label, onClear }: { label: string; onClear: () => void }) { return ( <button type="button" // 접근 가능한 지우기: 탭 순서에서 빼고 마우스·스크린리더로만 써요 tabIndex={-1} aria-label={label} data-slot="picker-clear" onMouseDown={(e) => e.preventDefault()} onClick={(e) => { e.stopPropagation(); onClear(); }} className={cn( indicatorClass, 'cursor-pointer self-stretch border-0 bg-transparent text-ds-icon-subtle hover:text-ds-icon outline-none', )} > <CircleX aria-hidden /> </button> );}const menuClass = 'z-[300] overflow-hidden rounded-ds-large bg-ds-surface-overlay shadow-ds-overlay outline-none data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=closed]:animate-out data-[state=closed]:fade-out-0';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];}/* ------------------------------------------------------------------------------------------------- * DatePicker * -----------------------------------------------------------------------------------------------*/export interface DatePickerProps { /** 필드 모양. subtle 은 평소 테두리·배경을 숨기고, none 은 항상 숨겨요. */ appearance?: Appearance; spacing?: Spacing; /** ISO 날짜 'YYYY-MM-DD' (제어) */ value?: string; defaultValue?: string; /** 값이 바뀌면 ISO 날짜나 빈 문자열로 호출돼요. */ onChange?: (value: string) => void; onFocus?: React.FocusEventHandler<HTMLInputElement>; onBlur?: React.FocusEventHandler<HTMLInputElement>; /** 달력 메뉴 열림 (제어) */ isOpen?: boolean; defaultIsOpen?: boolean; isDisabled?: boolean; isInvalid?: boolean; isRequired?: boolean; autoFocus?: boolean; id?: string; name?: string; /** 입력의 aria-label */ label?: string; 'aria-describedby'?: string; placeholder?: string; /** date-fns v1 토큰 표기 (예: 'YYYY-MM-DD'). 기본값은 로캘 기준 */ dateFormat?: string; formatDisplayLabel?: (value: string, dateFormat: string) => string; parseInputValue?: (date: string, dateFormat: string) => Date; /** 달력에서 고를 수 없는 날짜 */ disabled?: string[]; disabledDateFilter?: (date: string) => boolean; minDate?: string; maxDate?: string; locale?: string; weekStartDay?: WeekDay; /** 달력의 aria-label */ calendarLabel?: string; nextMonthLabel?: string; previousMonthLabel?: string; /** 지우기 버튼의 aria-label */ clearControlLabel?: string; /** 아이콘 숨기기 */ hideIcon?: boolean; /** 오른쪽에 달력 열기 버튼을 보여줘요. */ shouldShowCalendarButton?: boolean; openCalendarLabel?: string; inputLabel?: string; inputLabelId?: string; testId?: string; className?: string;}function DatePicker({ appearance = 'default', spacing = 'default', value: valueProp, defaultValue = '', onChange, onFocus, onBlur, isOpen: isOpenProp, defaultIsOpen = false, isDisabled = false, isInvalid = false, isRequired = false, autoFocus, id, name, label, 'aria-describedby': ariaDescribedBy, placeholder, dateFormat, formatDisplayLabel, parseInputValue, disabled, disabledDateFilter, minDate, maxDate, locale = 'ko-KR', weekStartDay, calendarLabel, nextMonthLabel, previousMonthLabel, clearControlLabel, hideIcon = false, shouldShowCalendarButton = false, openCalendarLabel = '달력 열기', inputLabel = '날짜 선택', inputLabelId, testId, className,}: DatePickerProps) { const [value, setValue] = useControllable(valueProp, defaultValue); const [open, setOpenInner] = useControllable(isOpenProp, defaultIsOpen); const [inputText, setInputText] = React.useState<string | null>(null); const [inputFocused, setInputFocused] = React.useState(false); const [focusCalendar, setFocusCalendar] = React.useState(false); const [view, setView] = React.useState<DateObj | null>(null); const containerRef = React.useRef<HTMLDivElement>(null); const contentRef = React.useRef<HTMLDivElement>(null); const inputRef = React.useRef<HTMLInputElement>(null); const calendarId = React.useId(); const format = dateFormat ?? defaultDateFormatOf(locale); const display = (iso: string) => iso ? (formatDisplayLabel ? formatDisplayLabel(iso, format) : formatDate(iso, format, locale)) : ''; const parse = (text: string): string | null => { if (parseInputValue) { const d = parseInputValue(text, format); return d instanceof Date && !Number.isNaN(d.getTime()) ? dateToLocalIso(d) : null; } return parseDate(text, dateFormat, locale); }; const viewFor = (iso: string): DateObj => { const d = parseIsoDate(iso) ?? parseIsoDate(getTodayIso())!; return d; }; const setOpen = (next: boolean) => { if (next && !open) setView(viewFor(value)); if (!next) setFocusCalendar(false); setOpenInner(next); }; const commit = (iso: string) => { setValue(iso); if (iso !== value) onChange?.(iso); }; const handleSelect = (iso: string) => { commit(iso); setInputText(null); setOpen(false); inputRef.current?.focus(); }; const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { const text = e.target.value; setInputText(text); if (!open) setOpen(true); const iso = parse(text); if (iso) setView(viewFor(iso)); }; const commitTyped = () => { if (inputText === null) return; if (inputText.trim() === '') commit(''); else { const iso = parse(inputText); if (iso) commit(iso); } setInputText(null); }; const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); if (!open) setOpen(true); // 달력의 포커스된 날짜로 키보드 포커스를 옮겨요 setFocusCalendar(true); } else if (e.key === 'Enter') { if (inputText !== null) { e.preventDefault(); commitTyped(); setOpen(false); } else if (!open) { e.preventDefault(); setOpen(true); } } else if (e.key === 'Escape') { setInputText(null); setOpen(false); } else if (e.key === 'Backspace' && inputText === null && value && !isDisabled) { // 값이 있는 상태에서 지우면 표시 문자열을 편집할 수 있게 한다 setInputText(display(value)); } }; const isFocused = !isDisabled && (inputFocused || open); const showClear = Boolean(value) && !hideIcon && !isDisabled; const showIcon = !hideIcon && !showClear && appearance !== 'subtle' && !shouldShowCalendarButton; const calendarButtonLabel = label ? `${label}, ${openCalendarLabel}` : openCalendarLabel; const shown = inputText ?? display(value); const calendarView = open ? (view ?? viewFor(value)) : null; const placeholderText = placeholder ?? display('1993-02-18'); return ( <PopoverPrimitive.Root open={open && !isDisabled} onOpenChange={setOpen} modal={false}> <PopoverPrimitive.Anchor asChild> <div ref={containerRef} data-slot="date-picker" data-testid={testId && `${testId}--container`} className={cn(pickerControlClass({ appearance, spacing, isDisabled, isFocused, isInvalid }), className)} onClick={() => { if (isDisabled) return; inputRef.current?.focus(); if (!open) setOpen(true); }} > <input ref={inputRef} id={id} role="combobox" aria-expanded={open} aria-haspopup="grid" aria-controls={open ? calendarId : undefined} aria-label={label ?? (shouldShowCalendarButton && !inputLabelId ? inputLabel : undefined)} aria-labelledby={shouldShowCalendarButton && !label ? inputLabelId : undefined} aria-describedby={ariaDescribedBy} aria-invalid={isInvalid || undefined} aria-required={isRequired || undefined} autoComplete="off" autoFocus={autoFocus} disabled={isDisabled} placeholder={placeholderText} value={shown} data-testid={testId && `${testId}--input`} className={inputClass} onChange={handleInputChange} onKeyDown={handleKeyDown} onFocus={(e) => { setInputFocused(true); onFocus?.(e); }} onBlur={(e) => { setInputFocused(false); const next = e.relatedTarget as Node | null; const inside = next && (containerRef.current?.contains(next) || contentRef.current?.contains(next)); if (!inside) { commitTyped(); setOpen(false); } onBlur?.(e); }} /> {name && <input type="hidden" name={name} value={value} />} {showClear && <ClearButton label={clearControlLabel ?? '날짜 지우기'} onClear={() => handleSelect('')} />} {showIcon && ( <span aria-hidden className={indicatorClass}> <CalendarIcon /> </span> )} {shouldShowCalendarButton && !hideIcon && ( <span className="flex shrink-0 items-center pr-ds-050"> <IconButton appearance="subtle" spacing="compact" isDisabled={isDisabled} label={calendarButtonLabel} icon={<CalendarIcon />} className="text-ds-icon-subtle" onClick={(e) => { e.stopPropagation(); setOpen(!open); setFocusCalendar(!open); }} /> </span> )} </div> </PopoverPrimitive.Anchor> <PopoverPrimitive.Portal> <PopoverPrimitive.Content ref={contentRef} id={calendarId} side="bottom" align="start" sideOffset={8} collisionPadding={8} className={menuClass} onOpenAutoFocus={(e) => e.preventDefault()} onCloseAutoFocus={(e) => e.preventDefault()} onInteractOutside={(e) => { if (containerRef.current?.contains(e.target as Node)) e.preventDefault(); }} onEscapeKeyDown={() => { setInputText(null); inputRef.current?.focus(); }} // 메뉴 클릭으로 입력 포커스를 잃지 않게 (원본 menu onMouseDown 동작) onMouseDown={(e) => { if (!focusCalendar) e.preventDefault(); }} > {calendarView && ( <Calendar label={calendarLabel} day={calendarView.day} month={calendarView.month} year={calendarView.year} selected={value ? [value] : []} disabled={disabled} disabledDateFilter={disabledDateFilter} minDate={minDate} maxDate={maxDate} locale={locale} weekStartDay={weekStartDay} nextMonthLabel={nextMonthLabel} previousMonthLabel={previousMonthLabel} shouldSetFocusOnCurrentDay={focusCalendar} testId={testId && `${testId}--calendar`} onChange={({ day, month, year }) => setView({ day: day || 1, month, year })} onSelect={({ iso }) => handleSelect(iso)} /> )} </PopoverPrimitive.Content> </PopoverPrimitive.Portal> </PopoverPrimitive.Root> );}/* ------------------------------------------------------------------------------------------------- * TimePicker * -----------------------------------------------------------------------------------------------*/export interface TimePickerProps { appearance?: Appearance; spacing?: Spacing; /** 'HH:mm' (제어) */ value?: string; defaultValue?: string; /** 값이 바뀌면 'HH:mm' 이나 빈 문자열로 호출돼요. */ onChange?: (value: string) => void; onFocus?: React.FocusEventHandler<HTMLElement>; onBlur?: React.FocusEventHandler<HTMLElement>; isOpen?: boolean; defaultIsOpen?: boolean; isDisabled?: boolean; isInvalid?: boolean; isRequired?: boolean; autoFocus?: boolean; id?: string; name?: string; label?: string; 'aria-describedby'?: string; placeholder?: string; /** 목록에 보여줄 시간 'HH:mm' 배열 */ times?: string[]; /** 목록에 없는 시간을 직접 입력할 수 있게 해요. */ timeIsEditable?: boolean; /** 토큰 표기 (HH, H, hh, h, mm, A, a). 기본값은 로캘 기준 */ timeFormat?: string; formatDisplayLabel?: (time: string, timeFormat: string) => string; parseInputValue?: (time: string, timeFormat: string) => string | Date; locale?: string; clearControlLabel?: string; hideIcon?: boolean; /** 오른쪽에 시간 목록 열기 버튼을 보여줘요. */ shouldShowTimeButton?: boolean; openTimeLabel?: string; testId?: string; className?: string;}function TimePicker({ appearance = 'default', spacing = 'default', value: valueProp, defaultValue = '', onChange, onFocus, onBlur, isOpen: isOpenProp, defaultIsOpen = false, isDisabled = false, isInvalid = false, isRequired = false, autoFocus, id, name, label, 'aria-describedby': ariaDescribedBy, placeholder, times = defaultTimes, timeIsEditable = false, timeFormat, formatDisplayLabel, parseInputValue, locale = 'ko-KR', clearControlLabel, hideIcon = false, shouldShowTimeButton = false, openTimeLabel = '시간 목록 열기', testId, className,}: TimePickerProps) { const [value, setValue] = useControllable(valueProp, defaultValue); const [open, setOpenInner] = useControllable(isOpenProp, defaultIsOpen); const [inputText, setInputText] = React.useState<string | null>(null); const [inputFocused, setInputFocused] = React.useState(false); const [highlight, setHighlight] = React.useState(-1); const containerRef = React.useRef<HTMLDivElement>(null); const contentRef = React.useRef<HTMLDivElement>(null); const inputRef = React.useRef<HTMLInputElement>(null); const listRef = React.useRef<HTMLDivElement>(null); const listId = React.useId(); const format = timeFormat ?? defaultTimeFormatOf(locale); const display = (t: string) => (t ? (formatDisplayLabel ? formatDisplayLabel(t, format) : formatTime(t, format, locale)) : ''); const parse = (text: string): string | null => { if (parseInputValue) { const r = parseInputValue(text, format); if (r instanceof Date) return Number.isNaN(r.getTime()) ? null : `${pad2(r.getHours())}:${pad2(r.getMinutes())}`; return parseTime(r); } return parseTime(text); }; const query = (inputText ?? '').trim().toLowerCase(); const options = times .map((t) => ({ value: t, label: display(t) })) .filter((o) => !query || o.label.toLowerCase().includes(query) || o.value.includes(query)); const setOpen = (next: boolean) => { if (next && !open) setHighlight(Math.max(0, times.indexOf(value))); setOpenInner(next); }; // 하이라이트된 옵션이 보이게 스크롤 React.useEffect(() => { if (!open || highlight < 0) return; const el = listRef.current?.querySelector<HTMLElement>(`[data-index="${highlight}"]`); el?.scrollIntoView({ block: 'nearest' }); }, [open, highlight]); const commit = (t: string) => { setValue(t); if (t !== value) onChange?.(t); }; const select = (t: string) => { commit(t); setInputText(null); setOpen(false); }; const commitTyped = () => { if (inputText === null) return; if (inputText.trim() === '') commit(''); else if (timeIsEditable) { const t = parse(inputText); if (t) commit(t); } setInputText(null); }; const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); if (!open) { setOpen(true); return; } const delta = e.key === 'ArrowDown' ? 1 : -1; setHighlight((h) => Math.min(options.length - 1, Math.max(0, h + delta))); } else if (e.key === 'Home' && open) { e.preventDefault(); setHighlight(0); } else if (e.key === 'End' && open) { e.preventDefault(); setHighlight(options.length - 1); } else if (e.key === 'Enter') { e.preventDefault(); if (!open) return setOpen(true); const typed = inputText !== null && timeIsEditable ? parse(inputText) : null; if (typed && !options.some((o) => o.value === typed)) return select(typed); const opt = options[highlight]; if (opt) select(opt.value); else { commitTyped(); setOpen(false); } } else if (e.key === 'Escape') { setInputText(null); setOpen(false); } else if (e.key === 'Backspace' && inputText === null && value && !isDisabled) { setInputText(display(value)); } }; const isFocused = !isDisabled && (inputFocused || open); const showClear = Boolean(value) && !hideIcon && !isDisabled; const showIcon = !hideIcon && !showClear && appearance !== 'subtle' && !shouldShowTimeButton; const activeId = open && options[highlight] ? `${listId}-${highlight}` : undefined; return ( <PopoverPrimitive.Root open={open && !isDisabled} onOpenChange={setOpen} modal={false}> <PopoverPrimitive.Anchor asChild> <div ref={containerRef} data-slot="time-picker" data-testid={testId && `${testId}--container`} className={cn(pickerControlClass({ appearance, spacing, isDisabled, isFocused, isInvalid }), className)} onClick={() => { if (isDisabled) return; inputRef.current?.focus(); if (!open) setOpen(true); }} > <input ref={inputRef} id={id} role="combobox" aria-expanded={open} aria-haspopup="listbox" aria-autocomplete="list" aria-controls={open ? listId : undefined} aria-activedescendant={activeId} aria-label={label} aria-describedby={ariaDescribedBy} aria-invalid={isInvalid || undefined} aria-required={isRequired || undefined} autoComplete="off" autoFocus={autoFocus} disabled={isDisabled} placeholder={placeholder ?? display('13:30')} value={inputText ?? display(value)} data-testid={testId && `${testId}--input`} className={inputClass} onChange={(e) => { setInputText(e.target.value); setHighlight(0); if (!open) setOpen(true); }} onKeyDown={handleKeyDown} onFocus={(e) => { setInputFocused(true); onFocus?.(e); }} onBlur={(e) => { setInputFocused(false); const next = e.relatedTarget as Node | null; const inside = next && (containerRef.current?.contains(next) || contentRef.current?.contains(next)); if (!inside) { commitTyped(); setOpen(false); } onBlur?.(e); }} /> {name && <input type="hidden" name={name} value={value} />} {showClear && <ClearButton label={clearControlLabel ?? '시간 지우기'} onClear={() => select('')} />} {showIcon && ( <span aria-hidden className={indicatorClass}> <Clock /> </span> )} {shouldShowTimeButton && !hideIcon && ( <span className="flex shrink-0 items-center pr-ds-050"> <IconButton appearance="subtle" spacing="compact" isDisabled={isDisabled} label={label ? `${label}, ${openTimeLabel}` : openTimeLabel} icon={<Clock />} className="text-ds-icon-subtle" onClick={(e) => { e.stopPropagation(); setOpen(!open); inputRef.current?.focus(); }} /> </span> )} </div> </PopoverPrimitive.Anchor> <PopoverPrimitive.Portal> <PopoverPrimitive.Content ref={contentRef} side="bottom" align="start" sideOffset={8} collisionPadding={8} className={cn(menuClass, 'w-(--radix-popover-trigger-width) min-w-40')} onOpenAutoFocus={(e) => e.preventDefault()} onCloseAutoFocus={(e) => e.preventDefault()} onInteractOutside={(e) => { if (containerRef.current?.contains(e.target as Node)) e.preventDefault(); }} onMouseDown={(e) => e.preventDefault()} > <div ref={listRef} id={listId} role="listbox" aria-label={label} className="max-h-[300px] overflow-y-auto py-ds-050" > {options.length === 0 ? ( <div className="px-ds-150 py-ds-075 text-center ds-font-body text-ds-text-subtlest"> {timeIsEditable ? 'Enter 를 눌러 입력한 시간을 써요' : '일치하는 시간이 없어요'} </div> ) : ( options.map((o, i) => { const isSelected = o.value === value; const isHighlighted = i === highlight; return ( <div key={o.value} id={`${listId}-${i}`} role="option" aria-selected={isSelected} data-index={i} data-selected={isSelected || undefined} data-highlighted={isHighlighted || undefined} onMouseMove={() => setHighlight(i)} onClick={() => select(o.value)} className={cn( 'cursor-pointer px-ds-150 py-ds-075 ds-font-body text-ds-text select-none', isHighlighted && 'bg-ds-background-neutral-subtle-hovered shadow-[inset_2px_0_0_0_var(--ds-border-selected)]', 'active:bg-ds-background-neutral-subtle-pressed', isSelected && 'bg-ds-background-selected text-ds-text-selected shadow-[inset_2px_0_0_0_var(--ds-border-selected)] active:bg-ds-background-selected-pressed', isSelected && isHighlighted && 'bg-ds-background-selected-hovered', )} > {o.label} </div> ); }) )} </div> </PopoverPrimitive.Content> </PopoverPrimitive.Portal> </PopoverPrimitive.Root> );}/* ------------------------------------------------------------------------------------------------- * DateTimePicker * -----------------------------------------------------------------------------------------------*/type DateTimeParts = { dateValue: string; timeValue: string; zoneValue: string };/** 'YYYY-MM-DDTHH:mm[:ss][Z|±hh:mm]' 을 날짜·시간·시간대로 나눠요. */function defaultParseValue(value: string): DateTimeParts { if (!value) return { dateValue: '', timeValue: '', zoneValue: '' }; const m = /^(\d{4}-\d{2}-\d{2})(?:T(\d{2}:\d{2})(?::\d{2}(?:\.\d+)?)?(Z|[+-]\d{2}(?::?\d{2})?)?)?$/.exec(value); if (!m) return { dateValue: '', timeValue: '', zoneValue: '' }; return { dateValue: m[1], timeValue: m[2] ?? '', zoneValue: m[3] ?? '' };}export interface DateTimePickerProps { appearance?: Appearance; spacing?: Spacing; /** ISO 날짜시간 (예: '2026-10-27T14:30' 또는 '2026-10-27T14:30+09:00') */ value?: string; defaultValue?: string; /** 날짜와 시간이 모두 채워졌거나 모두 비었을 때 ISO 문자열로 호출돼요. */ onChange?: (value: string) => void; onFocus?: React.FocusEventHandler<HTMLInputElement>; onBlur?: React.FocusEventHandler<HTMLInputElement>; isDisabled?: boolean; isInvalid?: boolean; isRequired?: boolean; autoFocus?: boolean; /** 날짜 입력에 붙는 id (label htmlFor 대상) */ id?: string; name?: string; 'aria-describedby'?: string; clearControlLabel?: string; locale?: string; /** DatePicker 에 넘길 props */ datePickerProps?: Omit<DatePickerProps, 'appearance' | 'spacing' | 'isDisabled' | 'value' | 'onChange'> & { onChange?: (value: string) => void; }; /** TimePicker 에 넘길 props */ timePickerProps?: Omit<TimePickerProps, 'appearance' | 'spacing' | 'isDisabled' | 'value' | 'onChange'> & { onChange?: (value: string) => void; }; parseValue?: (dateTimeValue: string, date: string, time: string, timezone: string) => DateTimeParts; testId?: string; className?: string;}function DateTimePicker({ appearance = 'default', spacing = 'default', value: valueProp, defaultValue = '', onChange, onFocus, onBlur, isDisabled = false, isInvalid = false, isRequired = false, autoFocus, id, name, 'aria-describedby': ariaDescribedBy, clearControlLabel = '날짜와 시간 지우기', locale = 'ko-KR', datePickerProps = {}, timePickerProps = {}, parseValue, testId, className,}: DateTimePickerProps) { const [value, setValue] = useControllable(valueProp, defaultValue); const parsed = React.useMemo(() => { const base = defaultParseValue(value); return parseValue ? parseValue(value, base.dateValue, base.timeValue, base.zoneValue) : base; }, [value, parseValue]); // 한쪽만 채워진 중간 상태는 내부에 들고 있는다 const [parts, setParts] = React.useState<DateTimeParts>(() => ({ dateValue: parsed.dateValue || datePickerProps.defaultValue || '', timeValue: parsed.timeValue || timePickerProps.defaultValue || '', zoneValue: parsed.zoneValue, })); React.useEffect(() => { if (valueProp === undefined) return; setParts((prev) => valueProp === '' && (prev.dateValue === '' || prev.timeValue === '') ? prev : parsed, ); }, [valueProp, parsed]); const [focused, setFocused] = React.useState(false); const update = (next: DateTimeParts) => { setParts(next); if (next.dateValue && next.timeValue) { const iso = `${next.dateValue}T${next.timeValue}${next.zoneValue}`; setValue(iso); if (iso !== value) onChange?.(iso); } else if (!next.dateValue && !next.timeValue) { setValue(''); if (value !== '') onChange?.(''); } else if (value) { // 완성된 값에서 한쪽을 지우면 값도 비운다 setValue(''); onChange?.(''); } }; const hasClear = Boolean(parts.dateValue || parts.timeValue) && !isDisabled; const innerHeight = spacing === 'compact' ? 'min-h-[30px]' : 'min-h-[38px]'; // platform-dst-motion-uplift-input ON: 포커스된 하위 입력에 2px 링 (바깥 테두리를 덮음) const segmentRing = cn( !isDisabled && 'transition-[background-color,border-color,box-shadow] duration-(--ds-motion-input-duration) ease-(--ds-motion-input-curve)', 'rounded-none has-[input:focus]:shadow-[0_0_0_var(--ds-border-width,1px)_var(--ds-border-focused),inset_0_0_0_var(--ds-border-width,1px)_var(--ds-border-focused)]', ); const { onChange: onDateChange, ...dateRest } = datePickerProps; const { onChange: onTimeChange, ...timeRest } = timePickerProps; return ( <div data-slot="date-time-picker" data-testid={testId} className={cn( pickerControlClass({ appearance, spacing, isDisabled, isFocused: focused && !isDisabled, isInvalid }), 'items-stretch', className, )} onFocus={() => setFocused(true)} onBlur={() => setFocused(false)} > {name && <input type="hidden" name={name} value={value} />} <div className="flex shrink-0 grow basis-1/2"> <DatePicker label="날짜" aria-describedby={ariaDescribedBy} autoFocus={autoFocus} id={id} isRequired={isRequired} locale={locale} onFocus={onFocus} onBlur={onBlur} testId={testId && `${testId}--datepicker`} {...dateRest} appearance="none" spacing={spacing} hideIcon isDisabled={isDisabled} value={parts.dateValue} onChange={(dateValue) => { onDateChange?.(dateValue); update({ ...parts, dateValue }); }} className={cn(innerHeight, segmentRing, 'rounded-s-[calc(var(--ds-radius-medium)-var(--ds-border-width,1px))]')} /> </div> <div className="flex grow basis-1/2"> <TimePicker label="시간" aria-describedby={ariaDescribedBy} isRequired={isRequired} locale={locale} onFocus={onFocus as React.FocusEventHandler<HTMLElement> | undefined} onBlur={onBlur as React.FocusEventHandler<HTMLElement> | undefined} testId={testId && `${testId}--timepicker`} {...timeRest} appearance="none" spacing={spacing} hideIcon isDisabled={isDisabled} value={parts.timeValue} onChange={(timeValue) => { onTimeChange?.(timeValue); update({ ...parts, timeValue }); }} className={cn(innerHeight, segmentRing, !hasClear && 'rounded-e-[calc(var(--ds-radius-medium)-var(--ds-border-width,1px))]')} /> </div> {hasClear && ( <ClearButton label={clearControlLabel} onClear={() => update({ dateValue: '', timeValue: '', zoneValue: parts.zoneValue })} /> )} </div> );}export { DatePicker, TimePicker, DateTimePicker };