podo-ui 1.1.14 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,9 @@
1
1
  export type DatePickerMode = 'instant' | 'period';
2
- export type DatePickerType = 'date' | 'time' | 'datetime';
2
+ export type DatePickerType = 'date' | 'time' | 'datetime' | 'hour';
3
+ /** 시(hour) 표시 포맷: 24시간제 | 12시간제(오전/오후) */
4
+ export type HourFormat = '24' | '12';
5
+ /** 시(hour) 선택 간격 */
6
+ export type HourStep = 1 | 2 | 3 | 4 | 6 | 12;
3
7
  /** 시간 값 (시, 분) */
4
8
  export interface TimeValue {
5
9
  hour: number;
@@ -73,12 +77,33 @@ export interface DatePickerProps {
73
77
  minDate?: Date | DateTimeLimit;
74
78
  /** 선택 가능한 최대 날짜 (Date 또는 { date, time }) */
75
79
  maxDate?: Date | DateTimeLimit;
76
- /** 분 단위 선택 간격 (1, 5, 10, 15, 30) 기본값: 1 */
80
+ /** 분 단위 선택 간격 (1, 5, 10, 15, 30) 기본값: 1
81
+ * type='hour'일 때는 무시됨 (분 컬럼이 렌더링되지 않음)
82
+ */
77
83
  minuteStep?: MinuteStep;
84
+ /**
85
+ * 시(hour) 표시 포맷 (type='hour'일 때만 유효)
86
+ * '24': 0~23시 (기본), '12': 오전/오후 1~12시
87
+ */
88
+ hourFormat?: HourFormat;
89
+ /**
90
+ * 선택 불가능한 시간 배열 (0~23, type='hour'일 때만 유효)
91
+ * hourFormat과 무관하게 항상 24시 기준 값
92
+ * 예: [0,1,2,3,4,5,22,23] → 새벽/심야 비활성
93
+ */
94
+ disabledHours?: number[];
95
+ /**
96
+ * 시(hour) 선택 간격 (type='hour'일 때만 유효)
97
+ * 예: hourStep=2 → 0,2,4,...,22 만 선택 가능
98
+ * 기본값: 1
99
+ */
100
+ hourStep?: HourStep;
78
101
  /**
79
102
  * 날짜/시간 표시 포맷
80
103
  * y: 년, m: 월, d: 일, h: 시, i: 분
81
104
  * 예시: "y-m-d", "y.m.d h:i", "y년 m월 d일 h시 i분"
105
+ * type='hour'에서는 이 prop이 무시되고, hourFormat에 따른
106
+ * 기본 라벨("h시" 또는 "오전/오후 h시")로 표시됨
82
107
  */
83
108
  format?: string;
84
109
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"datepicker.d.ts","sourceRoot":"","sources":["../../../react/molecule/datepicker.tsx"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"datepicker.d.ts","sourceRoot":"","sources":["../../../react/molecule/datepicker.tsx"],"names":[],"mappings":"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"}
@@ -475,7 +475,7 @@ const PeriodCalendar = ({ value, endValue, onSelect, viewDate, endViewDate, onVi
475
475
  return (_jsxs("div", { className: styles.periodCalendars, children: [_jsx("div", { className: styles.periodCalendarLeft, children: _jsx(Calendar, { value: value, endValue: endValue, mode: "period", onSelect: onSelect, viewDate: viewDate, onViewDateChange: onViewDateChange, showPrevNav: true, showNextNav: true, maxViewDate: isMobile ? undefined : endViewDate, disable: disable, enable: enable, minDate: minDate, maxDate: maxDate, yearRange: yearRange }) }), _jsx("div", { className: styles.periodCalendarRight, children: _jsx(Calendar, { value: value, endValue: endValue, mode: "period", onSelect: onSelect, viewDate: endViewDate, onViewDateChange: onEndViewDateChange, showPrevNav: true, showNextNav: true, minViewDate: viewDate, disable: disable, enable: enable, minDate: minDate, maxDate: maxDate, yearRange: yearRange }) })] }));
476
476
  };
477
477
  // Main DatePicker Component
478
- const DatePicker = ({ mode = 'instant', type = 'date', value, onChange, placeholder, disabled = false, showActions, align = 'left', className, disable, enable, minDate, maxDate, minuteStep = 1, format, initialCalendar, yearRange, portal = false, quickSelect = false, hideNavArrow = false, direction = 'down', onReset, }) => {
478
+ const DatePicker = ({ mode = 'instant', type = 'date', value, onChange, placeholder, disabled = false, showActions, align = 'left', className, disable, enable, minDate, maxDate, minuteStep = 1, hourFormat = '24', disabledHours, hourStep = 1, format, initialCalendar, yearRange, portal = false, quickSelect = false, hideNavArrow = false, direction = 'down', onReset, }) => {
479
479
  const [selectingPart, setSelectingPart] = useState(null);
480
480
  const [tempValue, setTempValue] = useState(value || {});
481
481
  const [navigationStep, setNavigationStep] = useState(() => {
@@ -526,7 +526,8 @@ const DatePicker = ({ mode = 'instant', type = 'date', value, onChange, placehol
526
526
  const [dropdownMaxWidth, setDropdownMaxWidth] = useState(null);
527
527
  // 실제 드롭다운 열림 방향 ('up' | 'down'), direction='auto'일 때만 동적으로 변경
528
528
  const [resolvedDirection, setResolvedDirection] = useState(direction === 'up' ? 'up' : 'down');
529
- const shouldShowActions = showActions ?? mode === 'period';
529
+ // hour-only는 native select 단일 입력이라 적용/초기화 액션이 필요 없음 → 즉시 commit
530
+ const shouldShowActions = showActions ?? (mode === 'period' && type !== 'hour');
530
531
  // 날짜 선택 시에만 드롭다운 표시 (시/분은 native select 사용)
531
532
  const isOpen = selectingPart === 'date' || selectingPart === 'endDate';
532
533
  // 드롭다운 max-width 계산 (드롭다운 위치 기준으로 남은 뷰포트 너비)
@@ -671,6 +672,14 @@ const DatePicker = ({ mode = 'instant', type = 'date', value, onChange, placehol
671
672
  }
672
673
  }, [value?.date, value?.endDate, quickSelect, mode]);
673
674
  const formatPeriodText = () => {
675
+ // hour-only: date 없이 time 만으로 표시
676
+ if (type === 'hour') {
677
+ const startText = tempValue.time ? formatHourLabel(tempValue.time.hour) : '';
678
+ const endText = tempValue.endTime ? formatHourLabel(tempValue.endTime.hour) : '';
679
+ if (startText && endText)
680
+ return `${startText} ~ ${endText}`;
681
+ return startText;
682
+ }
674
683
  if (!tempValue.date)
675
684
  return '';
676
685
  // format prop이 있으면 사용
@@ -806,8 +815,14 @@ const DatePicker = ({ mode = 'instant', type = 'date', value, onChange, placehol
806
815
  setNavOffset(0);
807
816
  };
808
817
  const handleReset = () => {
818
+ // 선택값만 비우고 팝오버는 열린 상태 유지
819
+ // selectingPart을 null로 두면 isOpen이 false가 되어 팝오버가 닫힘 → 'date'로 되돌려 시작일 입력 대기 상태 유지
809
820
  setTempValue({});
810
- setSelectingPart(null);
821
+ setSelectingPart('date');
822
+ setActivePresetKey(null);
823
+ setNavigationStep(null);
824
+ setNavigationAnchor(null);
825
+ setNavOffset(0);
811
826
  onReset?.();
812
827
  };
813
828
  const handleApply = () => {
@@ -951,16 +966,40 @@ const DatePicker = ({ mode = 'instant', type = 'date', value, onChange, placehol
951
966
  : `${date.getFullYear()} - ${String(date.getMonth() + 1).padStart(2, '0')} - ${String(date.getDate()).padStart(2, '0')}`;
952
967
  return (_jsx("button", { type: "button", className: `${styles.inputPart} ${isActive ? styles.active : ''}`, onClick: onClick, children: displayText }));
953
968
  };
969
+ // hour-only 모드: 24시간 기준 hour 값을 표시 라벨(한국어/12h) 로 변환
970
+ const formatHourLabel = (h) => {
971
+ if (hourFormat === '12') {
972
+ // 0시 → 오전 12시, 1~11시 → 오전 N시, 12시 → 오후 12시, 13~23시 → 오후 (N-12)시
973
+ const period = h < 12 ? '오전' : '오후';
974
+ const h12 = h % 12 === 0 ? 12 : h % 12;
975
+ return `${period} ${h12}시`;
976
+ }
977
+ return `${h}시`;
978
+ };
979
+ // hour-only 옵션 리스트 생성 (hourStep 적용)
980
+ const buildHourOptions = () => {
981
+ const step = hourStep ?? 1;
982
+ const list = [];
983
+ for (let h = 0; h < 24; h += step)
984
+ list.push(h);
985
+ return list;
986
+ };
954
987
  // Helper to render hour select
955
988
  const renderHourSelect = (time, part, isPlaceholder) => {
989
+ const isHourOnly = type === 'hour';
956
990
  const hour = time?.hour ?? 0;
957
- const hours = Array.from({ length: 24 }, (_, i) => i);
991
+ const hours = isHourOnly
992
+ ? buildHourOptions()
993
+ : Array.from({ length: 24 }, (_, i) => i);
958
994
  const isEnd = part === 'endHour';
959
995
  const currentDate = isEnd ? tempValue.endDate : tempValue.date;
960
996
  // minDate/maxDate 시간 제한 계산
961
997
  const minLimit = minDate ? extractDateTimeLimit(minDate) : null;
962
998
  const maxLimit = maxDate ? extractDateTimeLimit(maxDate) : null;
963
999
  const isHourDisabled = (h) => {
1000
+ // hour-only: disabledHours 우선 적용
1001
+ if (isHourOnly && disabledHours && disabledHours.includes(h))
1002
+ return true;
964
1003
  if (!currentDate)
965
1004
  return false;
966
1005
  // minDate와 같은 날짜인 경우
@@ -977,6 +1016,18 @@ const DatePicker = ({ mode = 'instant', type = 'date', value, onChange, placehol
977
1016
  };
978
1017
  const handleChange = (e) => {
979
1018
  const selectedHour = parseInt(e.target.value, 10);
1019
+ // hour-only: 분은 항상 0으로 정규화, minuteStep/min,maxDate time 보정 모두 무시
1020
+ if (isHourOnly) {
1021
+ const newTime = { hour: selectedHour, minute: 0 };
1022
+ const newValue = isEnd
1023
+ ? { ...tempValue, endTime: newTime }
1024
+ : { ...tempValue, time: newTime };
1025
+ setTempValue(newValue);
1026
+ if (!shouldShowActions) {
1027
+ onChange?.(newValue);
1028
+ }
1029
+ return;
1030
+ }
980
1031
  const currentTime = isEnd ? tempValue.endTime : tempValue.time;
981
1032
  let newMinute = currentTime?.minute ?? 0;
982
1033
  // 시간 변경 시 분이 범위를 벗어나면 자동 보정
@@ -1009,7 +1060,11 @@ const DatePicker = ({ mode = 'instant', type = 'date', value, onChange, placehol
1009
1060
  onChange?.(newValue);
1010
1061
  }
1011
1062
  };
1012
- return (_jsx("select", { className: `${styles.timeSelect} ${isPlaceholder ? styles.placeholder : ''}`, value: hour, onChange: handleChange, disabled: disabled, children: hours.map((h) => (_jsx("option", { value: h, disabled: isHourDisabled(h), children: String(h).padStart(2, '0') }, h))) }));
1063
+ // hour-only: 현재 값이 옵션 리스트에 없으면 가장 가까운 옵션으로 표시
1064
+ const displayHour = isHourOnly && !hours.includes(hour)
1065
+ ? (hours.reduce((prev, curr) => Math.abs(curr - hour) < Math.abs(prev - hour) ? curr : prev, hours[0] ?? 0))
1066
+ : hour;
1067
+ return (_jsx("select", { className: `${styles.timeSelect} ${isHourOnly ? styles.hourSelect : ''} ${isPlaceholder ? styles.placeholder : ''}`, value: displayHour, onChange: handleChange, disabled: disabled, "aria-label": isHourOnly ? '시간 선택' : undefined, children: hours.map((h) => (_jsx("option", { value: h, disabled: isHourDisabled(h), children: isHourOnly ? formatHourLabel(h) : String(h).padStart(2, '0') }, h))) }));
1013
1068
  };
1014
1069
  // Helper to render minute select
1015
1070
  const renderMinuteSelect = (time, part, isPlaceholder) => {
@@ -1086,6 +1141,13 @@ const DatePicker = ({ mode = 'instant', type = 'date', value, onChange, placehol
1086
1141
  }
1087
1142
  return (_jsx("div", { className: styles.inputContent, children: _jsxs("div", { className: styles.timeSection, children: [renderHourSelect(displayValue?.time, 'hour', !hasStartTime), _jsx("span", { className: styles.timeSeparator, children: ":" }), renderMinuteSelect(displayValue?.time, 'minute', !hasStartTime)] }) }));
1088
1143
  }
1144
+ if (type === 'hour') {
1145
+ // hour-only 모드: 분 컬럼/구분자 제거, 시 컬럼만 렌더
1146
+ if (mode === 'period') {
1147
+ return (_jsxs("div", { className: styles.inputContent, children: [_jsx("div", { className: `${styles.timeSection} ${styles.hourSection}`, children: renderHourSelect(displayValue?.time, 'hour', !hasStartTime) }), _jsx("span", { className: styles.separator, children: "~" }), _jsx("div", { className: `${styles.timeSection} ${styles.hourSection}`, children: renderHourSelect(displayValue?.endTime, 'endHour', !hasEndTime) })] }));
1148
+ }
1149
+ return (_jsx("div", { className: styles.inputContent, children: _jsx("div", { className: `${styles.timeSection} ${styles.hourSection}`, children: renderHourSelect(displayValue?.time, 'hour', !hasStartTime) }) }));
1150
+ }
1089
1151
  // datetime
1090
1152
  if (mode === 'period') {
1091
1153
  return (_jsxs("div", { className: styles.inputContent, children: [renderDateButton(displayValue?.date, selectingPart === 'date', () => handlePartClick('date'), !hasStartValue), _jsxs("div", { className: styles.timeSection, children: [renderHourSelect(displayValue?.time, 'hour', !hasStartTime), _jsx("span", { className: styles.timeSeparator, children: ":" }), renderMinuteSelect(displayValue?.time, 'minute', !hasStartTime)] }), _jsx("span", { className: styles.separator, children: "~" }), renderDateButton(displayValue?.endDate, selectingPart === 'endDate', () => handlePartClick('endDate'), !hasEndValue), _jsxs("div", { className: styles.timeSection, children: [renderHourSelect(displayValue?.endTime, 'endHour', !hasEndTime), _jsx("span", { className: styles.timeSeparator, children: ":" }), renderMinuteSelect(displayValue?.endTime, 'endMinute', !hasEndTime)] })] }));
@@ -1114,8 +1176,8 @@ const DatePicker = ({ mode = 'instant', type = 'date', value, onChange, placehol
1114
1176
  }
1115
1177
  return null;
1116
1178
  };
1117
- // 아이콘 결정 (time 타입은 icon-time, 나머지는 icon-calendar)
1118
- const inputIcon = type === 'time' ? 'icon-time' : 'icon-calendar';
1179
+ // 아이콘 결정 (time/hour 타입은 icon-time, 나머지는 icon-calendar)
1180
+ const inputIcon = type === 'time' || type === 'hour' ? 'icon-time' : 'icon-calendar';
1119
1181
  // 드롭다운 콘텐츠 렌더링
1120
1182
  const renderDropdown = () => {
1121
1183
  const dropdownContent = (_jsxs(_Fragment, { children: [renderDropdownContent(), shouldShowActions && (_jsxs("div", { className: styles.bottomActions, children: [_jsx("span", { className: styles.periodText, children: mode === 'period' && tempValue.date ? formatPeriodText() : '' }), _jsxs("div", { className: styles.actionButtons, children: [_jsxs("button", { type: "button", className: `${styles.actionButton} ${styles.reset}`, onClick: handleReset, children: [_jsx("i", { className: "icon-refresh" }), "\uCD08\uAE30\uD654"] }), _jsx("button", { type: "button", className: `${styles.actionButton} ${styles.apply}`, onClick: handleApply, children: "\uC801\uC6A9" })] })] }))] }));
@@ -130,6 +130,12 @@
130
130
  gap: 0;
131
131
  }
132
132
 
133
+ // hour-only 모드: 분 컬럼이 제거되므로 단일 select가 충분한 너비 확보
134
+ .hourSection {
135
+ flex: 1;
136
+ min-width: 0;
137
+ }
138
+
133
139
  .timeSelect {
134
140
  @include p4;
135
141
  appearance: none;
@@ -166,6 +172,14 @@
166
172
  }
167
173
  }
168
174
 
175
+ // hour-only select: 한국어 라벨("3시"/"오전 3시")을 위한 너비 확보
176
+ .hourSelect {
177
+ // 12h 라벨 "오전 12시" 폭 + 여백 정도
178
+ min-width: 72px;
179
+ text-align: center;
180
+ text-align-last: center;
181
+ }
182
+
169
183
  .timeSeparator {
170
184
  @include p4;
171
185
  color: color('text-body');
@@ -4,7 +4,13 @@
4
4
 
5
5
  // Types
6
6
  export type DatePickerMode = 'instant' | 'period';
7
- export type DatePickerType = 'date' | 'time' | 'datetime';
7
+ export type DatePickerType = 'date' | 'time' | 'datetime' | 'hour';
8
+
9
+ /** 시(hour) 표시 포맷: 24시간제 | 12시간제(오전/오후) */
10
+ export type HourFormat = '24' | '12';
11
+
12
+ /** 시(hour) 선택 간격 */
13
+ export type HourStep = 1 | 2 | 3 | 4 | 6 | 12;
8
14
 
9
15
  export interface TimeValue {
10
16
  hour: number;
@@ -194,9 +200,15 @@
194
200
  minDate?: Date | DateTimeLimit;
195
201
  /** Maximum selectable date */
196
202
  maxDate?: Date | DateTimeLimit;
197
- /** Minute selection step */
203
+ /** Minute selection step. Ignored when type='hour'. */
198
204
  minuteStep?: MinuteStep;
199
- /** Date/time format pattern */
205
+ /** Hour display format (type='hour' only). '24': 0~23시, '12': 오전/오후 표기. Default '24'. */
206
+ hourFormat?: HourFormat;
207
+ /** Hours (0-23) that cannot be selected (type='hour' only). Always 24h reference. */
208
+ disabledHours?: number[];
209
+ /** Step interval for hour options (type='hour' only). Default 1. */
210
+ hourStep?: HourStep;
211
+ /** Date/time format pattern. Ignored when type='hour'. */
200
212
  format?: string;
201
213
  /** Initial calendar display month for period mode */
202
214
  initialCalendar?: InitialCalendar;
@@ -227,6 +239,9 @@
227
239
  minDate,
228
240
  maxDate,
229
241
  minuteStep = 1,
242
+ hourFormat = '24',
243
+ disabledHours,
244
+ hourStep = 1,
230
245
  format,
231
246
  initialCalendar,
232
247
  yearRange,
@@ -255,14 +270,33 @@
255
270
  const today = new Date();
256
271
 
257
272
  // Computed
258
- const shouldShowActions = $derived(showActions ?? mode === 'period');
273
+ // hour-only는 native select 단일 입력이라 적용/초기화 액션이 필요 없음 → 즉시 commit
274
+ const shouldShowActions = $derived(showActions ?? (mode === 'period' && type !== 'hour'));
259
275
  const isOpen = $derived(selectingPart === 'date' || selectingPart === 'endDate');
260
276
  const displayValue = $derived(shouldShowActions ? tempValue : value);
261
277
  const hasStartValue = $derived(!!displayValue?.date);
262
278
  const hasEndValue = $derived(!!displayValue?.endDate);
263
- const inputIcon = $derived(type === 'time' ? 'icon-time' : 'icon-calendar');
279
+ const inputIcon = $derived(type === 'time' || type === 'hour' ? 'icon-time' : 'icon-calendar');
264
280
  const showNavigation = $derived(quickSelect && mode === 'period' && !hideNavArrow);
265
281
 
282
+ // hour-only 라벨 (24h="3시", 12h="오전 3시")
283
+ const formatHourLabel = (h: number): string => {
284
+ if (hourFormat === '12') {
285
+ const period = h < 12 ? '오전' : '오후';
286
+ const h12 = h % 12 === 0 ? 12 : h % 12;
287
+ return `${period} ${h12}시`;
288
+ }
289
+ return `${h}시`;
290
+ };
291
+
292
+ // hour-only 옵션 리스트 (hourStep 적용)
293
+ const buildHourOptions = (): number[] => {
294
+ const step = hourStep ?? 1;
295
+ const list: number[] = [];
296
+ for (let h = 0; h < 24; h += step) list.push(h);
297
+ return list;
298
+ };
299
+
266
300
  // 드롭다운 방향 결정 (direction='auto'일 때 공간 측정)
267
301
  let resolvedDirection = $state<'up' | 'down'>(direction === 'up' ? 'up' : 'down');
268
302
  $effect(() => {
@@ -533,6 +567,14 @@
533
567
 
534
568
  // Format display
535
569
  const formatPeriodText = () => {
570
+ // hour-only: date 없이 time 만으로 표시
571
+ if (type === 'hour') {
572
+ const startText = tempValue.time ? formatHourLabel(tempValue.time.hour) : '';
573
+ const endText = tempValue.endTime ? formatHourLabel(tempValue.endTime.hour) : '';
574
+ if (startText && endText) return `${startText} ~ ${endText}`;
575
+ return startText;
576
+ }
577
+
536
578
  if (!tempValue.date) return '';
537
579
 
538
580
  if (format) {
@@ -790,8 +832,14 @@
790
832
  };
791
833
 
792
834
  const handleReset = () => {
835
+ // 선택값만 비우고 팝오버는 열린 상태 유지
836
+ // selectingPart을 null로 두면 isOpen이 false가 되어 팝오버가 닫힘 → 'date'로 되돌려 시작일 입력 대기 상태 유지
793
837
  tempValue = {};
794
- selectingPart = null;
838
+ selectingPart = 'date';
839
+ activePresetKeyState = null;
840
+ navigationStep = null;
841
+ navigationAnchor = null;
842
+ navOffset = 0;
795
843
  onreset?.();
796
844
  };
797
845
 
@@ -841,7 +889,10 @@
841
889
  const target = e.target as HTMLSelectElement;
842
890
  const hour = parseInt(target.value);
843
891
  const currentTime = isEnd ? tempValue.endTime : tempValue.time;
844
- const newTime: TimeValue = { hour, minute: currentTime?.minute ?? 0 };
892
+ // hour-only: 분을 0으로 정규화
893
+ const newTime: TimeValue = type === 'hour'
894
+ ? { hour, minute: 0 }
895
+ : { hour, minute: currentTime?.minute ?? 0 };
845
896
 
846
897
  const newValue = isEnd
847
898
  ? { ...tempValue, endTime: newTime }
@@ -870,9 +921,22 @@
870
921
  };
871
922
 
872
923
  // Time select options
873
- const hours = Array.from({ length: 24 }, (_, i) => i);
924
+ // type='hour'면 hourStep 기반 옵션, 아니면 0~23 전체
925
+ const hours = $derived<number[]>(type === 'hour' ? buildHourOptions() : Array.from({ length: 24 }, (_, i) => i));
874
926
  const minutes = Array.from({ length: Math.ceil(60 / minuteStep) }, (_, i) => i * minuteStep);
875
927
 
928
+ // hour-only: 옵션에 비활성 여부 (disabledHours + min/maxDate time)
929
+ const isHourOptionDisabled = (h: number, isEnd = false): boolean => {
930
+ if (type === 'hour' && disabledHours && disabledHours.includes(h)) return true;
931
+ const currentDate = isEnd ? tempValue.endDate : tempValue.date;
932
+ if (!currentDate) return false;
933
+ const minLimit = minDate ? (minDate instanceof Date ? { date: minDate } : minDate) : null;
934
+ const maxLimit = maxDate ? (maxDate instanceof Date ? { date: maxDate } : maxDate) : null;
935
+ if (minLimit?.time && isSameDay(currentDate, minLimit.date) && h < minLimit.time.hour) return true;
936
+ if (maxLimit?.time && isSameDay(currentDate, maxLimit.date) && h > maxLimit.time.hour) return true;
937
+ return false;
938
+ };
939
+
876
940
  // Calendar cell class computation
877
941
  const getCellClass = (dayInfo: { day: number; date: Date; isOther: boolean; isDisabled: boolean }) => {
878
942
  const { date, isOther, isDisabled } = dayInfo;
@@ -991,6 +1055,50 @@
991
1055
  {formatDateDisplay(displayValue?.date)}
992
1056
  </button>
993
1057
  {/if}
1058
+ {:else if type === 'hour'}
1059
+ {#if mode === 'period'}
1060
+ <div class="{styles.timeSection} {styles.hourSection}">
1061
+ <select
1062
+ class="{styles.timeSelect} {styles.hourSelect} {!displayValue?.time ? styles.placeholder : ''}"
1063
+ value={displayValue?.time?.hour ?? 0}
1064
+ onchange={(e) => handleHourChange(e, false)}
1065
+ {disabled}
1066
+ aria-label="시간 선택"
1067
+ >
1068
+ {#each hours as h}
1069
+ <option value={h} disabled={isHourOptionDisabled(h, false)}>{formatHourLabel(h)}</option>
1070
+ {/each}
1071
+ </select>
1072
+ </div>
1073
+ <span class={styles.separator}>~</span>
1074
+ <div class="{styles.timeSection} {styles.hourSection}">
1075
+ <select
1076
+ class="{styles.timeSelect} {styles.hourSelect} {!displayValue?.endTime ? styles.placeholder : ''}"
1077
+ value={displayValue?.endTime?.hour ?? 0}
1078
+ onchange={(e) => handleHourChange(e, true)}
1079
+ {disabled}
1080
+ aria-label="종료 시간 선택"
1081
+ >
1082
+ {#each hours as h}
1083
+ <option value={h} disabled={isHourOptionDisabled(h, true)}>{formatHourLabel(h)}</option>
1084
+ {/each}
1085
+ </select>
1086
+ </div>
1087
+ {:else}
1088
+ <div class="{styles.timeSection} {styles.hourSection}">
1089
+ <select
1090
+ class="{styles.timeSelect} {styles.hourSelect} {!displayValue?.time ? styles.placeholder : ''}"
1091
+ value={displayValue?.time?.hour ?? 0}
1092
+ onchange={(e) => handleHourChange(e, false)}
1093
+ {disabled}
1094
+ aria-label="시간 선택"
1095
+ >
1096
+ {#each hours as h}
1097
+ <option value={h} disabled={isHourOptionDisabled(h, false)}>{formatHourLabel(h)}</option>
1098
+ {/each}
1099
+ </select>
1100
+ </div>
1101
+ {/if}
994
1102
  {:else if type === 'time'}
995
1103
  {#if mode === 'period'}
996
1104
  <div class={styles.timeSection}>
@@ -1,5 +1,9 @@
1
1
  export type DatePickerMode = 'instant' | 'period';
2
- export type DatePickerType = 'date' | 'time' | 'datetime';
2
+ export type DatePickerType = 'date' | 'time' | 'datetime' | 'hour';
3
+ /** 시(hour) 표시 포맷: 24시간제 | 12시간제(오전/오후) */
4
+ export type HourFormat = '24' | '12';
5
+ /** 시(hour) 선택 간격 */
6
+ export type HourStep = 1 | 2 | 3 | 4 | 6 | 12;
3
7
  export interface TimeValue {
4
8
  hour: number;
5
9
  minute: number;
@@ -58,9 +62,15 @@ interface Props {
58
62
  minDate?: Date | DateTimeLimit;
59
63
  /** Maximum selectable date */
60
64
  maxDate?: Date | DateTimeLimit;
61
- /** Minute selection step */
65
+ /** Minute selection step. Ignored when type='hour'. */
62
66
  minuteStep?: MinuteStep;
63
- /** Date/time format pattern */
67
+ /** Hour display format (type='hour' only). '24': 0~23시, '12': 오전/오후 표기. Default '24'. */
68
+ hourFormat?: HourFormat;
69
+ /** Hours (0-23) that cannot be selected (type='hour' only). Always 24h reference. */
70
+ disabledHours?: number[];
71
+ /** Step interval for hour options (type='hour' only). Default 1. */
72
+ hourStep?: HourStep;
73
+ /** Date/time format pattern. Ignored when type='hour'. */
64
74
  format?: string;
65
75
  /** Initial calendar display month for period mode */
66
76
  initialCalendar?: InitialCalendar;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "podo-ui",
3
- "version": "1.1.14",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
5
  "author": "hada0127 <work@tarucy.net>",
6
6
  "license": "MIT",
@@ -5,11 +5,12 @@
5
5
  "documentation": "https://podoui.com/components/datepicker",
6
6
  "import": {
7
7
  "react": "import { DatePicker } from 'podo-ui'",
8
+ "svelte": "import { DatePicker } from 'podo-ui/svelte'",
8
9
  "vanilla": "import 'podo-ui/vanilla/datepicker'"
9
10
  },
10
11
  "props": [
11
12
  { "name": "mode", "type": "'instant' | 'period'", "default": "'instant'", "description": "Single date or date range" },
12
- { "name": "type", "type": "'date' | 'time' | 'datetime'", "default": "'date'" },
13
+ { "name": "type", "type": "'date' | 'time' | 'datetime' | 'hour'", "default": "'date'", "description": "Value type. 'hour' renders only an hour selector (no minutes)." },
13
14
  { "name": "value", "type": "DatePickerValue", "required": false },
14
15
  { "name": "onChange", "type": "(value: DatePickerValue) => void", "required": false },
15
16
  { "name": "placeholder", "type": "string", "required": false },
@@ -20,8 +21,11 @@
20
21
  { "name": "enable", "type": "DateCondition[]", "required": false, "description": "Only enable specific dates" },
21
22
  { "name": "minDate", "type": "Date | DateTimeLimit", "required": false },
22
23
  { "name": "maxDate", "type": "Date | DateTimeLimit", "required": false },
23
- { "name": "minuteStep", "type": "1 | 5 | 10 | 15 | 20 | 30", "default": "1" },
24
- { "name": "format", "type": "string", "required": false, "description": "Custom format: y-m-d h:i" },
24
+ { "name": "minuteStep", "type": "1 | 5 | 10 | 15 | 20 | 30", "default": "1", "description": "Ignored when type='hour' (no minute column rendered)." },
25
+ { "name": "hourFormat", "type": "'24' | '12'", "default": "'24'", "required": false, "description": "Hour display format (type='hour' only). '24' shows 0시~23시, '12' shows 오전/오후 1~12시." },
26
+ { "name": "disabledHours", "type": "number[]", "required": false, "description": "Hours (0-23) that cannot be selected (type='hour' only). Always uses 24h reference values regardless of hourFormat." },
27
+ { "name": "hourStep", "type": "1 | 2 | 3 | 4 | 6 | 12", "default": "1", "required": false, "description": "Step interval for hour options (type='hour' only). e.g., hourStep=2 → 0,2,4,...,22." },
28
+ { "name": "format", "type": "string", "required": false, "description": "Custom format: y-m-d h:i. Ignored when type='hour' (use hourFormat instead)." },
25
29
  { "name": "initialCalendar", "type": "{ start?: CalendarInitial, end?: CalendarInitial }", "required": false, "description": "Initial calendar display month for left/right calendars. When specified, takes priority over value.date/value.endDate so the calendar opens at the explicitly requested month. CalendarInitial: 'now' | 'prevMonth' | 'nextMonth' | Date" },
26
30
  { "name": "yearRange", "type": "YearRange", "required": false, "description": "Year selection range (default: ±100 years from current year, takes priority over minDate/maxDate)" },
27
31
  { "name": "quickSelect", "type": "boolean", "default": "false", "required": false, "description": "Show quick select preset panel with common date ranges (period mode only). Presets: today, yesterday, this week, last week, last 7/30 days, this/last month. Disabled presets based on minDate/maxDate. When quickSelect=true and mode='period', left/right arrow navigation buttons appear around the input. Arrows shift the selected range by the preset unit (e.g., 7 days for weekly) or by the manually selected range size. Arrows are disabled when no range is selected or when shifting would exceed minDate/maxDate." },
@@ -67,6 +71,26 @@
67
71
  {
68
72
  "title": "Quick Select with arrow navigation",
69
73
  "code": "<DatePicker mode=\"period\" quickSelect />"
74
+ },
75
+ {
76
+ "title": "Hour-only (24h)",
77
+ "code": "<DatePicker type=\"hour\" value={{ time: { hour: 9, minute: 0 } }} onChange={(v) => setHour(v.time?.hour ?? null)} />"
78
+ },
79
+ {
80
+ "title": "Hour-only (12h)",
81
+ "code": "<DatePicker type=\"hour\" hourFormat=\"12\" value={value} onChange={setValue} />"
82
+ },
83
+ {
84
+ "title": "Hour-only with disabled hours (no midnight/early morning)",
85
+ "code": "<DatePicker type=\"hour\" disabledHours={[0, 1, 2, 3, 4, 5, 22, 23]} value={value} onChange={setValue} />"
86
+ },
87
+ {
88
+ "title": "Hour-only with step (every 2 hours)",
89
+ "code": "<DatePicker type=\"hour\" hourStep={2} value={value} onChange={setValue} />"
90
+ },
91
+ {
92
+ "title": "Hour range (period)",
93
+ "code": "<DatePicker type=\"hour\" mode=\"period\" value={value} onChange={setValue} />"
70
94
  }
71
95
  ],
72
96
  "related": ["field", "input"]
package/public/ai.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "podo-ui",
3
- "version": "1.1.14",
3
+ "version": "1.2.0",
4
4
  "description": "Design system: SCSS + React + Svelte 5",
5
5
  "philosophy": "Maximum flexibility with minimal JS dependency",
6
6
  "install": "npm install podo-ui",
@@ -135,6 +135,19 @@
135
135
  gap: 0;
136
136
  }
137
137
 
138
+ /* hour-only 모드: 분 컬럼이 제거되므로 단일 select가 충분한 너비 확보 */
139
+ .podo-datepicker__hour-section {
140
+ flex: 1;
141
+ min-width: 0;
142
+ }
143
+
144
+ /* hour-only select: "오전 12시"~"오후 11시" 라벨 폭 확보 */
145
+ .podo-datepicker__hour-select {
146
+ min-width: 72px;
147
+ text-align: center;
148
+ text-align-last: center;
149
+ }
150
+
138
151
  .podo-datepicker__time-select {
139
152
  appearance: none;
140
153
  -webkit-appearance: none;