/** * @title Time Picker * @description 휠을 굴려 오전·오후, 시, 분을 선택하는 12시간제 시간 선택 컴포넌트예요. */'use client';import * as React from 'react';import { cn } from '@/lib/utils';import { WheelPicker, WheelPickerItemLabel, type WheelPickerColumn, type WheelPickerProps,} from '@/components/ui/wheel-picker';// SEED Rootage spec: time-picker.yaml// - root: height 220px// - selectionIndicator: height 44px, cornerRadius $radius.r2, color $color.bg.neutral-weak// - item: height 44px, paddingX $dimension.x4, t10-static, font-weight medium,// color $color.fg.disabled → selected $color.fg.neutral// Wheel Picker(medium, 5개 표시)를 그대로 재사용해요./** 24시간 형식의 시간 값이에요. 오전·오후는 별도 필드로 나누지 않아요. */export interface TimePickerValue { /** 0–23 */ hour: number; /** 0–59 */ minute: number;}export type MinuteStep = 1 | 5 | 10 | 15 | 30;type ColumnType = 'period' | 'hour' | 'minute';const DEFAULT_ARIA_LABELS = { ko: { root: '시간 선택', period: '오전/오후', hour: '시', minute: '분' }, en: { root: 'Select time', period: 'AM/PM', hour: 'Hour', minute: 'Minute' }, ja: { root: '時刻を選択', period: '午前/午後', hour: '時', minute: '分' },} as const;function getLanguage(locale: string) { try { return new Intl.Locale(locale).language; } catch { return 'ko'; }}function getDefaultAriaLabels(locale: string) { const language = getLanguage(locale); if (language === 'ko') return DEFAULT_ARIA_LABELS.ko; if (language === 'ja') return DEFAULT_ARIA_LABELS.ja; return DEFAULT_ARIA_LABELS.en;}// 런타임마다 ICU 데이터가 달라(예: Node 는 ko 를 "PM", 브라우저는 "오후") SSR 하이드레이션이 어긋나지 않게 고정한다.const DAY_PERIODS: Record<string, { am: string; pm: string }> = { ko: { am: '오전', pm: '오후' }, ja: { am: '午前', pm: '午後' }, zh: { am: '上午', pm: '下午' }, en: { am: 'AM', pm: 'PM' },};/** locale 에 맞는 오전·오후 표기와 컬럼 순서를 구해요. */function getLocaleInfo(locale: string) { const format = (hour: number) => new Intl.DateTimeFormat(locale, { hour: 'numeric', hourCycle: 'h12' }).formatToParts(new Date(2000, 0, 1, hour)); const amParts = format(9); const pmParts = format(21); const fixed = DAY_PERIODS[getLanguage(locale)]; const am = fixed?.am ?? amParts.find((part) => part.type === 'dayPeriod')?.value ?? 'AM'; const pm = fixed?.pm ?? pmParts.find((part) => part.type === 'dayPeriod')?.value ?? 'PM'; const periodIndex = amParts.findIndex((part) => part.type === 'dayPeriod'); const hourIndex = amParts.findIndex((part) => part.type === 'hour'); const order: ColumnType[] = periodIndex !== -1 && periodIndex < hourIndex ? ['period', 'hour', 'minute'] : ['hour', 'minute', 'period']; return { am, pm, order, numberFormat: new Intl.NumberFormat(locale, { useGrouping: false }) };}function assertValue(value: TimePickerValue | undefined, name: string) { if (!value) return; if (!Number.isInteger(value.hour) || value.hour < 0 || value.hour > 23) throw new RangeError(`TimePicker: ${name}.hour 는 0–23 사이의 정수여야 해요.`); if (!Number.isInteger(value.minute) || value.minute < 0 || value.minute > 59) throw new RangeError(`TimePicker: ${name}.minute 는 0–59 사이의 정수여야 해요.`);}/** 시각 전체를 기준으로 Minute Step 에 맞게 반올림해요. (23:59, step 5 → 00:00) */function normalize(value: TimePickerValue, step: MinuteStep): TimePickerValue { const total = Math.round((value.hour * 60 + value.minute) / step) * step; const wrapped = ((total % 1440) + 1440) % 1440; return { hour: Math.floor(wrapped / 60), minute: wrapped % 60 };}export interface TimePickerProps extends Omit<WheelPickerProps, 'columns' | 'size' | 'itemSize' | 'visibleItemCount' | 'readOnly' | 'defaultValue'> { /** 선택된 시간 (제어). */ value?: TimePickerValue; /** 처음 선택될 시간 (비제어). 생략하면 00:00 이에요. */ defaultValue?: TimePickerValue; /** 스크롤이 정착해 값이 확정되면 호출돼요. */ onValueChange?: (value: TimePickerValue) => void; /** 분 휠의 선택 간격이에요. */ minuteStep?: MinuteStep; /** 오전·오후 표기, 숫자 표기, 컬럼 순서를 정해요. */ locale?: string; /** 모든 컬럼의 조작과 포커스를 막아요. */ disabled?: boolean; /** 오전·오후 컬럼의 접근성 이름이에요. */ periodAriaLabel?: string; /** 시 컬럼의 접근성 이름이에요. */ hourAriaLabel?: string; /** 분 컬럼의 접근성 이름이에요. */ minuteAriaLabel?: string;}const MINUTE_STEPS: readonly MinuteStep[] = [1, 5, 10, 15, 30];function TimePicker({ value: valueProp, defaultValue, onValueChange, minuteStep = 5, locale = 'ko-KR', disabled = false, periodAriaLabel, hourAriaLabel, minuteAriaLabel, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, ...props}: TimePickerProps) { if (!MINUTE_STEPS.includes(minuteStep)) throw new RangeError('TimePicker: minuteStep 은 1, 5, 10, 15, 30 중 하나예요.'); assertValue(valueProp, 'value'); assertValue(defaultValue, 'defaultValue'); const [uncontrolled, setUncontrolled] = React.useState<TimePickerValue>(() => normalize(defaultValue ?? { hour: 0, minute: 0 }, minuteStep), ); const isControlled = valueProp !== undefined; const value = normalize(isControlled ? valueProp : uncontrolled, minuteStep); const valueRef = React.useRef(value); valueRef.current = value; const setValue = (next: TimePickerValue) => { const current = valueRef.current; if (next.hour === current.hour && next.minute === current.minute) return; valueRef.current = next; if (!isControlled) setUncontrolled(next); onValueChange?.(next); }; const info = React.useMemo(() => getLocaleInfo(locale), [locale]); const defaultLabels = getDefaultAriaLabels(locale); const labels = { period: periodAriaLabel ?? defaultLabels.period, hour: hourAriaLabel ?? defaultLabels.hour, minute: minuteAriaLabel ?? defaultLabels.minute, }; const periodOptions = React.useMemo( () => [ { value: 'am', label: info.am }, { value: 'pm', label: info.pm }, ], [info], ); // 12시간제: 12 · 1 · 2 … 11 const hourOptions = React.useMemo( () => Array.from({ length: 12 }, (_, index) => { const hour = index === 0 ? 12 : index; return { value: String(hour), label: info.numberFormat.format(hour) }; }), [info], ); const minuteOptions = React.useMemo( () => Array.from({ length: 60 / minuteStep }, (_, index) => { const minute = index * minuteStep; return { value: String(minute), label: info.numberFormat.format(minute).padStart(2, info.numberFormat.format(0)) }; }), [info, minuteStep], ); const isPm = value.hour >= 12; const hour12 = value.hour % 12 === 0 ? 12 : value.hour % 12; const columnsByType: Record<ColumnType, WheelPickerColumn> = { period: { id: 'period', 'aria-label': labels.period, options: periodOptions, value: isPm ? 'pm' : 'am', valueChangeBehavior: 'smooth', onValueChange: (next) => { const current = valueRef.current; setValue({ hour: (current.hour % 12) + (next === 'pm' ? 12 : 0), minute: current.minute }); }, }, hour: { id: 'hour', 'aria-label': labels.hour, options: hourOptions, value: String(hour12), loop: true, itemAlign: 'end', onValueChange: (next) => { const current = valueRef.current; const pm = current.hour >= 12; setValue({ hour: (Number(next) % 12) + (pm ? 12 : 0), minute: current.minute }); }, }, minute: { id: 'minute', 'aria-label': labels.minute, options: minuteOptions, value: String(value.minute), loop: true, // 분 컬럼은 순환하지만 경계를 넘어도 시는 유지돼요. onValueChange: (next) => setValue({ hour: valueRef.current.hour, minute: Number(next) }), }, }; const columns = info.order.map((type) => ({ ...columnsByType[type], renderLabel: (option: { label: React.ReactNode }) => ( <WheelPickerItemLabel data-slot="time-picker-item" className="px-x4 text-t10-static font-medium tabular-nums"> {option.label} </WheelPickerItemLabel> ), })); return ( <WheelPicker data-slot="time-picker" columns={columns} size="medium" itemSize={44} visibleItemCount={5} disabled={disabled} aria-label={ariaLabelledby ? ariaLabel : (ariaLabel ?? defaultLabels.root)} aria-labelledby={ariaLabelledby} className={cn('w-full', className)} {...props} /> );}export { TimePicker };