podo-ui 1.1.15 → 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이 있으면 사용
@@ -957,16 +966,40 @@ const DatePicker = ({ mode = 'instant', type = 'date', value, onChange, placehol
957
966
  : `${date.getFullYear()} - ${String(date.getMonth() + 1).padStart(2, '0')} - ${String(date.getDate()).padStart(2, '0')}`;
958
967
  return (_jsx("button", { type: "button", className: `${styles.inputPart} ${isActive ? styles.active : ''}`, onClick: onClick, children: displayText }));
959
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
+ };
960
987
  // Helper to render hour select
961
988
  const renderHourSelect = (time, part, isPlaceholder) => {
989
+ const isHourOnly = type === 'hour';
962
990
  const hour = time?.hour ?? 0;
963
- const hours = Array.from({ length: 24 }, (_, i) => i);
991
+ const hours = isHourOnly
992
+ ? buildHourOptions()
993
+ : Array.from({ length: 24 }, (_, i) => i);
964
994
  const isEnd = part === 'endHour';
965
995
  const currentDate = isEnd ? tempValue.endDate : tempValue.date;
966
996
  // minDate/maxDate 시간 제한 계산
967
997
  const minLimit = minDate ? extractDateTimeLimit(minDate) : null;
968
998
  const maxLimit = maxDate ? extractDateTimeLimit(maxDate) : null;
969
999
  const isHourDisabled = (h) => {
1000
+ // hour-only: disabledHours 우선 적용
1001
+ if (isHourOnly && disabledHours && disabledHours.includes(h))
1002
+ return true;
970
1003
  if (!currentDate)
971
1004
  return false;
972
1005
  // minDate와 같은 날짜인 경우
@@ -983,6 +1016,18 @@ const DatePicker = ({ mode = 'instant', type = 'date', value, onChange, placehol
983
1016
  };
984
1017
  const handleChange = (e) => {
985
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
+ }
986
1031
  const currentTime = isEnd ? tempValue.endTime : tempValue.time;
987
1032
  let newMinute = currentTime?.minute ?? 0;
988
1033
  // 시간 변경 시 분이 범위를 벗어나면 자동 보정
@@ -1015,7 +1060,11 @@ const DatePicker = ({ mode = 'instant', type = 'date', value, onChange, placehol
1015
1060
  onChange?.(newValue);
1016
1061
  }
1017
1062
  };
1018
- 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))) }));
1019
1068
  };
1020
1069
  // Helper to render minute select
1021
1070
  const renderMinuteSelect = (time, part, isPlaceholder) => {
@@ -1092,6 +1141,13 @@ const DatePicker = ({ mode = 'instant', type = 'date', value, onChange, placehol
1092
1141
  }
1093
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)] }) }));
1094
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
+ }
1095
1151
  // datetime
1096
1152
  if (mode === 'period') {
1097
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)] })] }));
@@ -1120,8 +1176,8 @@ const DatePicker = ({ mode = 'instant', type = 'date', value, onChange, placehol
1120
1176
  }
1121
1177
  return null;
1122
1178
  };
1123
- // 아이콘 결정 (time 타입은 icon-time, 나머지는 icon-calendar)
1124
- 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';
1125
1181
  // 드롭다운 콘텐츠 렌더링
1126
1182
  const renderDropdown = () => {
1127
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) {
@@ -847,7 +889,10 @@
847
889
  const target = e.target as HTMLSelectElement;
848
890
  const hour = parseInt(target.value);
849
891
  const currentTime = isEnd ? tempValue.endTime : tempValue.time;
850
- 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 };
851
896
 
852
897
  const newValue = isEnd
853
898
  ? { ...tempValue, endTime: newTime }
@@ -876,9 +921,22 @@
876
921
  };
877
922
 
878
923
  // Time select options
879
- 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));
880
926
  const minutes = Array.from({ length: Math.ceil(60 / minuteStep) }, (_, i) => i * minuteStep);
881
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
+
882
940
  // Calendar cell class computation
883
941
  const getCellClass = (dayInfo: { day: number; date: Date; isOther: boolean; isDisabled: boolean }) => {
884
942
  const { date, isOther, isDisabled } = dayInfo;
@@ -997,6 +1055,50 @@
997
1055
  {formatDateDisplay(displayValue?.date)}
998
1056
  </button>
999
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}
1000
1102
  {:else if type === 'time'}
1001
1103
  {#if mode === 'period'}
1002
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.15",
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.15",
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;