podo-ui 0.8.0 → 0.8.2

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.
@@ -0,0 +1,1134 @@
1
+ /*!
2
+ * Podo UI DatePicker v0.8.2
3
+ * https://podoui.com
4
+ * MIT License
5
+ */
6
+ /**
7
+ * Podo UI DatePicker - Vanilla JS
8
+ * A pure JavaScript date picker component without dependencies
9
+ *
10
+ * @version 0.8.0
11
+ * @license MIT
12
+ */
13
+
14
+ (function (global, factory) {
15
+ typeof exports === 'object' && typeof module !== 'undefined'
16
+ ? (module.exports = factory())
17
+ : typeof define === 'function' && define.amd
18
+ ? define(factory)
19
+ : ((global = typeof globalThis !== 'undefined' ? globalThis : global || self),
20
+ (global.PodoDatePicker = factory()));
21
+ })(this, function () {
22
+ 'use strict';
23
+
24
+ // CSS class prefix
25
+ const PREFIX = 'podo-datepicker';
26
+
27
+ // Default texts (Korean)
28
+ const DEFAULT_TEXTS = {
29
+ weekDays: ['일', '월', '화', '수', '목', '금', '토'],
30
+ months: ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월'],
31
+ yearSuffix: '년',
32
+ reset: '초기화',
33
+ apply: '적용',
34
+ };
35
+
36
+ // ============================================
37
+ // Helper Functions
38
+ // ============================================
39
+
40
+ /**
41
+ * CalendarInitial 값을 Date로 변환
42
+ * @param {string|Date|undefined} initial - 'now', 'prevMonth', 'nextMonth', or Date
43
+ * @param {Date} fallback - 기본값
44
+ * @returns {Date}
45
+ */
46
+ function resolveCalendarInitial(initial, fallback) {
47
+ if (!initial) return fallback;
48
+ if (initial instanceof Date) return initial;
49
+
50
+ const now = new Date();
51
+ switch (initial) {
52
+ case 'now':
53
+ return new Date(now.getFullYear(), now.getMonth(), 1);
54
+ case 'prevMonth':
55
+ return new Date(now.getFullYear(), now.getMonth() - 1, 1);
56
+ case 'nextMonth':
57
+ return new Date(now.getFullYear(), now.getMonth() + 1, 1);
58
+ default:
59
+ return fallback;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * 커스텀 포맷으로 날짜/시간 포맷팅
65
+ * y: 년(4자리), m: 월(2자리), d: 일(2자리), h: 시(2자리), i: 분(2자리)
66
+ * @param {Date|undefined} date
67
+ * @param {Object|undefined} time - { hour, minute }
68
+ * @param {string} pattern
69
+ * @returns {string}
70
+ */
71
+ function formatWithPattern(date, time, pattern) {
72
+ if (!date && !time) return '';
73
+
74
+ let result = pattern;
75
+
76
+ if (date) {
77
+ result = result.replace(/y/g, String(date.getFullYear()));
78
+ result = result.replace(/m/g, String(date.getMonth() + 1).padStart(2, '0'));
79
+ result = result.replace(/d/g, String(date.getDate()).padStart(2, '0'));
80
+ }
81
+
82
+ if (time) {
83
+ result = result.replace(/h/g, String(time.hour).padStart(2, '0'));
84
+ result = result.replace(/i/g, String(time.minute).padStart(2, '0'));
85
+ }
86
+
87
+ return result;
88
+ }
89
+
90
+ /**
91
+ * 날짜만 표시하는 포맷 추출 (시간 부분 제거)
92
+ * @param {string|undefined} format
93
+ * @returns {string|undefined}
94
+ */
95
+ function getDateOnlyFormat(format) {
96
+ if (!format) return undefined;
97
+ return format.replace(/\s*h[:\s]*i[분]?/g, '').replace(/\s*h시\s*i분/g, '').trim();
98
+ }
99
+
100
+ function formatDate(date) {
101
+ const year = date.getFullYear();
102
+ const month = String(date.getMonth() + 1).padStart(2, '0');
103
+ const day = String(date.getDate()).padStart(2, '0');
104
+ return `${year} - ${month} - ${day}`;
105
+ }
106
+
107
+ function formatTime(hour, minute) {
108
+ return `${String(hour).padStart(2, '0')} : ${String(minute).padStart(2, '0')}`;
109
+ }
110
+
111
+ function isSameDay(date1, date2) {
112
+ if (!date1 || !date2) return false;
113
+ return (
114
+ date1.getFullYear() === date2.getFullYear() &&
115
+ date1.getMonth() === date2.getMonth() &&
116
+ date1.getDate() === date2.getDate()
117
+ );
118
+ }
119
+
120
+ function isInRange(date, start, end) {
121
+ const d = new Date(date.getFullYear(), date.getMonth(), date.getDate());
122
+ const s = new Date(start.getFullYear(), start.getMonth(), start.getDate());
123
+ const e = new Date(end.getFullYear(), end.getMonth(), end.getDate());
124
+ return d >= s && d <= e;
125
+ }
126
+
127
+ function isInRangeExclusive(date, start, end) {
128
+ const d = new Date(date.getFullYear(), date.getMonth(), date.getDate());
129
+ const s = new Date(start.getFullYear(), start.getMonth(), start.getDate());
130
+ const e = new Date(end.getFullYear(), end.getMonth(), end.getDate());
131
+ return d > s && d < e;
132
+ }
133
+
134
+ function getDaysInMonth(year, month) {
135
+ return new Date(year, month + 1, 0).getDate();
136
+ }
137
+
138
+ function getFirstDayOfMonth(year, month) {
139
+ return new Date(year, month, 1).getDay();
140
+ }
141
+
142
+ function isDateRange(condition) {
143
+ return typeof condition === 'object' && condition !== null && 'from' in condition && 'to' in condition;
144
+ }
145
+
146
+ function matchesCondition(date, condition) {
147
+ if (typeof condition === 'function') {
148
+ return condition(date);
149
+ }
150
+ if (isDateRange(condition)) {
151
+ return isInRange(date, condition.from, condition.to);
152
+ }
153
+ // Date type
154
+ return isSameDay(date, condition);
155
+ }
156
+
157
+ function isDateDisabled(date, disable, enable) {
158
+ // If enable is specified: disable if not matching any condition
159
+ if (enable && enable.length > 0) {
160
+ const isEnabled = enable.some((condition) => matchesCondition(date, condition));
161
+ return !isEnabled;
162
+ }
163
+
164
+ // If disable is specified: disable if matching any condition
165
+ if (disable && disable.length > 0) {
166
+ return disable.some((condition) => matchesCondition(date, condition));
167
+ }
168
+
169
+ return false;
170
+ }
171
+
172
+ function isDateTimeLimit(value) {
173
+ return typeof value === 'object' && value !== null && 'date' in value && !(value instanceof Date);
174
+ }
175
+
176
+ function extractDateTimeLimit(limit) {
177
+ if (isDateTimeLimit(limit)) {
178
+ return { date: limit.date, time: limit.time };
179
+ }
180
+ return { date: limit };
181
+ }
182
+
183
+ function isBeforeMinDate(date, minDate) {
184
+ if (!minDate) return false;
185
+ const { date: minDateValue } = extractDateTimeLimit(minDate);
186
+ const d = new Date(date.getFullYear(), date.getMonth(), date.getDate());
187
+ const m = new Date(minDateValue.getFullYear(), minDateValue.getMonth(), minDateValue.getDate());
188
+ return d < m;
189
+ }
190
+
191
+ function isAfterMaxDate(date, maxDate) {
192
+ if (!maxDate) return false;
193
+ const { date: maxDateValue } = extractDateTimeLimit(maxDate);
194
+ const d = new Date(date.getFullYear(), date.getMonth(), date.getDate());
195
+ const m = new Date(maxDateValue.getFullYear(), maxDateValue.getMonth(), maxDateValue.getDate());
196
+ return d > m;
197
+ }
198
+
199
+ function createElement(tag, className, content) {
200
+ const el = document.createElement(tag);
201
+ if (className) el.className = className;
202
+ if (content !== undefined) {
203
+ if (typeof content === 'string' || typeof content === 'number') {
204
+ el.textContent = content;
205
+ } else if (content instanceof HTMLElement) {
206
+ el.appendChild(content);
207
+ }
208
+ }
209
+ return el;
210
+ }
211
+
212
+ // ============================================
213
+ // PodoDatePicker Class
214
+ // ============================================
215
+
216
+ class PodoDatePicker {
217
+ /**
218
+ * @param {HTMLElement|string} container - Container element or selector
219
+ * @param {Object} options - DatePicker options
220
+ * @param {string} [options.mode='instant'] - 'instant' or 'period'
221
+ * @param {string} [options.type='date'] - 'date', 'time', or 'datetime'
222
+ * @param {Object} [options.value] - Initial value { date, time, endDate, endTime }
223
+ * @param {Function} [options.onChange] - Change callback
224
+ * @param {string} [options.placeholder] - Placeholder text
225
+ * @param {boolean} [options.disabled=false] - Disabled state
226
+ * @param {boolean} [options.showActions] - Show action buttons
227
+ * @param {string} [options.align='left'] - Dropdown alignment 'left' or 'right'
228
+ * @param {Array} [options.disable] - Disable conditions
229
+ * @param {Array} [options.enable] - Enable conditions (only these dates are selectable)
230
+ * @param {Date|Object} [options.minDate] - Minimum selectable date
231
+ * @param {Date|Object} [options.maxDate] - Maximum selectable date
232
+ * @param {number} [options.minuteStep=1] - Minute step (1, 5, 10, 15, 20, 30)
233
+ * @param {Object} [options.texts] - Custom texts for localization
234
+ * @param {string} [options.format] - Date/time format (y: year, m: month, d: day, h: hour, i: minute)
235
+ * @param {Object} [options.initialCalendar] - Initial calendar display month { start, end }
236
+ */
237
+ constructor(container, options = {}) {
238
+ this.container =
239
+ typeof container === 'string' ? document.querySelector(container) : container;
240
+
241
+ if (!this.container) {
242
+ throw new Error('PodoDatePicker: Container element not found');
243
+ }
244
+
245
+ // Options
246
+ this.mode = options.mode || 'instant';
247
+ this.type = options.type || 'date';
248
+ this.value = options.value || {};
249
+ this.tempValue = { ...this.value };
250
+ this.onChange = options.onChange;
251
+ this.placeholder = options.placeholder;
252
+ this.disabled = options.disabled || false;
253
+ this.showActions = options.showActions ?? this.mode === 'period';
254
+ this.align = options.align || 'left';
255
+ this.disable = options.disable || [];
256
+ this.enable = options.enable || [];
257
+ this.minDate = options.minDate;
258
+ this.maxDate = options.maxDate;
259
+ this.minuteStep = options.minuteStep || 1;
260
+ this.texts = { ...DEFAULT_TEXTS, ...options.texts };
261
+ this.format = options.format;
262
+ this.initialCalendar = options.initialCalendar || {};
263
+
264
+ // State
265
+ this.isOpen = false;
266
+ this.selectingPart = null;
267
+
268
+ // 초기 달력 표시 월 계산
269
+ if (this.value.date) {
270
+ this.viewDate = new Date(this.value.date);
271
+ } else if (this.initialCalendar.start) {
272
+ this.viewDate = resolveCalendarInitial(this.initialCalendar.start, new Date());
273
+ } else {
274
+ this.viewDate = new Date();
275
+ }
276
+
277
+ if (this.value.endDate) {
278
+ this.endViewDate = new Date(
279
+ this.value.endDate.getFullYear(),
280
+ this.value.endDate.getMonth() + 1,
281
+ 1
282
+ );
283
+ } else if (this.initialCalendar.end) {
284
+ this.endViewDate = resolveCalendarInitial(this.initialCalendar.end, new Date());
285
+ } else {
286
+ this.endViewDate = new Date(
287
+ this.viewDate.getFullYear(),
288
+ this.viewDate.getMonth() + 1,
289
+ 1
290
+ );
291
+ }
292
+
293
+ // Build UI
294
+ this.render();
295
+ this.bindEvents();
296
+ }
297
+
298
+ // ============================================
299
+ // Rendering
300
+ // ============================================
301
+
302
+ render() {
303
+ this.container.innerHTML = '';
304
+ this.container.className = PREFIX;
305
+
306
+ // Input wrapper
307
+ this.inputEl = createElement('div', `${PREFIX}__input`);
308
+ if (this.disabled) this.inputEl.classList.add(`${PREFIX}__input--disabled`);
309
+
310
+ this.inputContentEl = createElement('div', `${PREFIX}__input-content`);
311
+ this.renderInputContent();
312
+ this.inputEl.appendChild(this.inputContentEl);
313
+
314
+ // Icon
315
+ const iconClass = this.type === 'time' ? 'icon-time' : 'icon-calendar';
316
+ this.iconEl = createElement('i', `${PREFIX}__icon ${iconClass}`);
317
+ this.inputEl.appendChild(this.iconEl);
318
+
319
+ this.container.appendChild(this.inputEl);
320
+
321
+ // Dropdown
322
+ this.dropdownEl = createElement(
323
+ 'div',
324
+ `${PREFIX}__dropdown ${this.align === 'right' ? `${PREFIX}__dropdown--right` : ''}`
325
+ );
326
+ this.dropdownEl.style.display = 'none';
327
+ this.container.appendChild(this.dropdownEl);
328
+ }
329
+
330
+ renderInputContent() {
331
+ this.inputContentEl.innerHTML = '';
332
+ const displayValue = this.showActions ? this.tempValue : this.value;
333
+
334
+ if (this.type === 'date') {
335
+ this.renderDateInput(displayValue);
336
+ } else if (this.type === 'time') {
337
+ this.renderTimeInput(displayValue);
338
+ } else {
339
+ // datetime
340
+ this.renderDateTimeInput(displayValue);
341
+ }
342
+ }
343
+
344
+ renderDateInput(displayValue) {
345
+ // Start date button
346
+ this.startDateBtn = this.createDateButton(displayValue.date, 'date');
347
+ this.inputContentEl.appendChild(this.startDateBtn);
348
+
349
+ if (this.mode === 'period') {
350
+ const sep = createElement('span', `${PREFIX}__separator`, '~');
351
+ this.inputContentEl.appendChild(sep);
352
+
353
+ this.endDateBtn = this.createDateButton(displayValue.endDate, 'endDate');
354
+ this.inputContentEl.appendChild(this.endDateBtn);
355
+ }
356
+ }
357
+
358
+ renderTimeInput(displayValue) {
359
+ const startTimeSection = this.createTimeSection(displayValue.time, 'hour', 'minute');
360
+ this.inputContentEl.appendChild(startTimeSection);
361
+
362
+ if (this.mode === 'period') {
363
+ const sep = createElement('span', `${PREFIX}__separator`, '~');
364
+ this.inputContentEl.appendChild(sep);
365
+
366
+ const endTimeSection = this.createTimeSection(displayValue.endTime, 'endHour', 'endMinute');
367
+ this.inputContentEl.appendChild(endTimeSection);
368
+ }
369
+ }
370
+
371
+ renderDateTimeInput(displayValue) {
372
+ // Start date
373
+ this.startDateBtn = this.createDateButton(displayValue.date, 'date');
374
+ this.inputContentEl.appendChild(this.startDateBtn);
375
+
376
+ // Start time
377
+ const startTimeSection = this.createTimeSection(displayValue.time, 'hour', 'minute');
378
+ this.inputContentEl.appendChild(startTimeSection);
379
+
380
+ if (this.mode === 'period') {
381
+ const sep = createElement('span', `${PREFIX}__separator`, '~');
382
+ this.inputContentEl.appendChild(sep);
383
+
384
+ // End date
385
+ this.endDateBtn = this.createDateButton(displayValue.endDate, 'endDate');
386
+ this.inputContentEl.appendChild(this.endDateBtn);
387
+
388
+ // End time
389
+ const endTimeSection = this.createTimeSection(displayValue.endTime, 'endHour', 'endMinute');
390
+ this.inputContentEl.appendChild(endTimeSection);
391
+ }
392
+ }
393
+
394
+ createDateButton(date, part) {
395
+ const btn = createElement('button', `${PREFIX}__part`);
396
+ btn.type = 'button';
397
+
398
+ const dateFormat = getDateOnlyFormat(this.format);
399
+
400
+ if (!date) {
401
+ btn.classList.add(`${PREFIX}__part--placeholder`);
402
+ // format이 있으면 placeholder도 포맷에 맞게 표시
403
+ const placeholderText = dateFormat
404
+ ? dateFormat.replace(/y/g, 'YYYY').replace(/m/g, 'MM').replace(/d/g, 'DD')
405
+ : 'YYYY - MM - DD';
406
+ btn.textContent = placeholderText;
407
+ } else {
408
+ // format prop이 있으면 사용 (날짜만)
409
+ const displayText = dateFormat
410
+ ? formatWithPattern(date, null, dateFormat)
411
+ : formatDate(date);
412
+ btn.textContent = displayText;
413
+ }
414
+ btn.dataset.part = part;
415
+ return btn;
416
+ }
417
+
418
+ createTimeSection(time, hourPart, minutePart) {
419
+ const section = createElement('div', `${PREFIX}__time-section`);
420
+
421
+ // Hour select
422
+ const hourSelect = this.createHourSelect(time, hourPart);
423
+ section.appendChild(hourSelect);
424
+
425
+ const sep = createElement('span', `${PREFIX}__time-separator`, ':');
426
+ section.appendChild(sep);
427
+
428
+ // Minute select
429
+ const minuteSelect = this.createMinuteSelect(time, minutePart);
430
+ section.appendChild(minuteSelect);
431
+
432
+ return section;
433
+ }
434
+
435
+ createHourSelect(time, part) {
436
+ const select = createElement('select', `${PREFIX}__time-select`);
437
+ if (!time) select.classList.add(`${PREFIX}__time-select--placeholder`);
438
+ if (this.disabled) select.disabled = true;
439
+
440
+ const isEnd = part === 'endHour';
441
+ const currentDate = isEnd ? this.tempValue.endDate : this.tempValue.date;
442
+
443
+ for (let h = 0; h < 24; h++) {
444
+ const opt = createElement('option', null, String(h).padStart(2, '0'));
445
+ opt.value = h;
446
+
447
+ // Check hour disabled by minDate/maxDate
448
+ if (this.isHourDisabled(h, currentDate)) {
449
+ opt.disabled = true;
450
+ }
451
+
452
+ select.appendChild(opt);
453
+ }
454
+
455
+ select.value = time?.hour ?? 0;
456
+ select.dataset.part = part;
457
+ return select;
458
+ }
459
+
460
+ createMinuteSelect(time, part) {
461
+ const select = createElement('select', `${PREFIX}__time-select`);
462
+ if (!time) select.classList.add(`${PREFIX}__time-select--placeholder`);
463
+ if (this.disabled) select.disabled = true;
464
+
465
+ const isEnd = part === 'endMinute';
466
+ const currentDate = isEnd ? this.tempValue.endDate : this.tempValue.date;
467
+ const currentTime = isEnd ? this.tempValue.endTime : this.tempValue.time;
468
+
469
+ for (let m = 0; m < 60; m += this.minuteStep) {
470
+ const opt = createElement('option', null, String(m).padStart(2, '0'));
471
+ opt.value = m;
472
+
473
+ // Check minute disabled
474
+ if (this.isMinuteDisabled(m, currentDate, currentTime)) {
475
+ opt.disabled = true;
476
+ }
477
+
478
+ select.appendChild(opt);
479
+ }
480
+
481
+ let minute = time?.minute ?? 0;
482
+ // Adjust to valid minute step
483
+ if (minute % this.minuteStep !== 0) {
484
+ minute = Math.floor(minute / this.minuteStep) * this.minuteStep;
485
+ }
486
+ select.value = minute;
487
+ select.dataset.part = part;
488
+ return select;
489
+ }
490
+
491
+ isHourDisabled(h, currentDate) {
492
+ if (!currentDate) return false;
493
+
494
+ const minLimit = this.minDate ? extractDateTimeLimit(this.minDate) : null;
495
+ const maxLimit = this.maxDate ? extractDateTimeLimit(this.maxDate) : null;
496
+
497
+ if (minLimit?.time && isSameDay(currentDate, minLimit.date)) {
498
+ if (h < minLimit.time.hour) return true;
499
+ }
500
+ if (maxLimit?.time && isSameDay(currentDate, maxLimit.date)) {
501
+ if (h > maxLimit.time.hour) return true;
502
+ }
503
+ return false;
504
+ }
505
+
506
+ isMinuteDisabled(m, currentDate, currentTime) {
507
+ if (!currentDate || !currentTime) return false;
508
+
509
+ const minLimit = this.minDate ? extractDateTimeLimit(this.minDate) : null;
510
+ const maxLimit = this.maxDate ? extractDateTimeLimit(this.maxDate) : null;
511
+
512
+ if (minLimit?.time && isSameDay(currentDate, minLimit.date) && currentTime.hour === minLimit.time.hour) {
513
+ if (m < minLimit.time.minute) return true;
514
+ }
515
+ if (maxLimit?.time && isSameDay(currentDate, maxLimit.date) && currentTime.hour === maxLimit.time.hour) {
516
+ if (m > maxLimit.time.minute) return true;
517
+ }
518
+ return false;
519
+ }
520
+
521
+ renderDropdown() {
522
+ this.dropdownEl.innerHTML = '';
523
+
524
+ // Calendar(s)
525
+ if (this.mode === 'period') {
526
+ this.renderPeriodCalendars();
527
+ } else {
528
+ this.renderCalendar(this.viewDate, (date) => this.handleViewDateChange(date));
529
+ }
530
+
531
+ // Action buttons
532
+ if (this.showActions) {
533
+ this.renderActions();
534
+ }
535
+ }
536
+
537
+ renderCalendar(viewDate, onViewDateChange, opts = {}) {
538
+ const calendar = createElement('div', `${PREFIX}__calendar`);
539
+
540
+ // Navigation
541
+ const nav = this.renderCalendarNav(viewDate, onViewDateChange, opts);
542
+ calendar.appendChild(nav);
543
+
544
+ // Grid
545
+ const grid = this.renderCalendarGrid(viewDate);
546
+ calendar.appendChild(grid);
547
+
548
+ this.dropdownEl.appendChild(calendar);
549
+ return calendar;
550
+ }
551
+
552
+ renderPeriodCalendars() {
553
+ const wrapper = createElement('div', `${PREFIX}__period-calendars`);
554
+
555
+ // Left calendar
556
+ const leftCal = createElement('div', `${PREFIX}__period-calendar-left`);
557
+ const leftCalendar = this.createCalendarElement(
558
+ this.viewDate,
559
+ (date) => this.handleViewDateChange(date),
560
+ { maxViewDate: this.endViewDate }
561
+ );
562
+ leftCal.appendChild(leftCalendar);
563
+ wrapper.appendChild(leftCal);
564
+
565
+ // Right calendar
566
+ const rightCal = createElement('div', `${PREFIX}__period-calendar-right`);
567
+ const rightCalendar = this.createCalendarElement(
568
+ this.endViewDate,
569
+ (date) => this.handleEndViewDateChange(date),
570
+ { minViewDate: this.viewDate }
571
+ );
572
+ rightCal.appendChild(rightCalendar);
573
+ wrapper.appendChild(rightCal);
574
+
575
+ this.dropdownEl.appendChild(wrapper);
576
+ }
577
+
578
+ createCalendarElement(viewDate, onViewDateChange, opts = {}) {
579
+ const calendar = createElement('div', `${PREFIX}__calendar`);
580
+
581
+ // Navigation
582
+ const nav = this.renderCalendarNav(viewDate, onViewDateChange, opts);
583
+ calendar.appendChild(nav);
584
+
585
+ // Grid
586
+ const grid = this.renderCalendarGrid(viewDate);
587
+ calendar.appendChild(grid);
588
+
589
+ return calendar;
590
+ }
591
+
592
+ renderCalendarNav(viewDate, onViewDateChange, opts = {}) {
593
+ const nav = createElement('div', `${PREFIX}__calendar-nav`);
594
+ const year = viewDate.getFullYear();
595
+ const month = viewDate.getMonth();
596
+
597
+ // Calculate navigation limits
598
+ const minViewDate = opts.minViewDate;
599
+ const maxViewDate = opts.maxViewDate;
600
+
601
+ const minYear = minViewDate?.getFullYear();
602
+ const minMonth = minViewDate?.getMonth();
603
+ const maxYear = maxViewDate?.getFullYear();
604
+ const maxMonth = maxViewDate?.getMonth();
605
+
606
+ const isPrevDisabled = minViewDate
607
+ ? year < minYear || (year === minYear && month <= minMonth)
608
+ : false;
609
+ const isNextDisabled = maxViewDate
610
+ ? year > maxYear || (year === maxYear && month >= maxMonth)
611
+ : false;
612
+
613
+ // Prev button
614
+ const prevBtn = createElement('button', `${PREFIX}__nav-button`);
615
+ prevBtn.type = 'button';
616
+ prevBtn.innerHTML = '<i class="icon-expand-left"></i>';
617
+ if (isPrevDisabled) prevBtn.disabled = true;
618
+ prevBtn.addEventListener('click', () => {
619
+ if (!isPrevDisabled) {
620
+ onViewDateChange(new Date(year, month - 1, 1));
621
+ this.renderDropdown();
622
+ }
623
+ });
624
+ nav.appendChild(prevBtn);
625
+
626
+ // Title (Year/Month selects)
627
+ const title = createElement('div', `${PREFIX}__nav-title`);
628
+
629
+ // Year select
630
+ const yearWrapper = createElement('div', `${PREFIX}__nav-select-wrapper`);
631
+ const yearSelect = createElement('select', `${PREFIX}__nav-select`);
632
+ const currentYear = new Date().getFullYear();
633
+ for (let y = currentYear - 10; y <= currentYear + 10; y++) {
634
+ if (minYear !== undefined && y < minYear) continue;
635
+ if (maxYear !== undefined && y > maxYear) continue;
636
+ const opt = createElement('option', null, `${y}${this.texts.yearSuffix}`);
637
+ opt.value = y;
638
+ yearSelect.appendChild(opt);
639
+ }
640
+ yearSelect.value = year;
641
+ yearSelect.addEventListener('change', (e) => {
642
+ onViewDateChange(new Date(parseInt(e.target.value), month, 1));
643
+ this.renderDropdown();
644
+ });
645
+ yearWrapper.appendChild(yearSelect);
646
+ title.appendChild(yearWrapper);
647
+
648
+ // Month select
649
+ const monthWrapper = createElement('div', `${PREFIX}__nav-select-wrapper`);
650
+ const monthSelect = createElement('select', `${PREFIX}__nav-select`);
651
+ for (let m = 0; m < 12; m++) {
652
+ // Filter based on view limits
653
+ if (minYear !== undefined && minMonth !== undefined && year === minYear && m < minMonth) continue;
654
+ if (maxYear !== undefined && maxMonth !== undefined && year === maxYear && m > maxMonth) continue;
655
+ const opt = createElement('option', null, this.texts.months[m]);
656
+ opt.value = m;
657
+ monthSelect.appendChild(opt);
658
+ }
659
+ monthSelect.value = month;
660
+ monthSelect.addEventListener('change', (e) => {
661
+ onViewDateChange(new Date(year, parseInt(e.target.value), 1));
662
+ this.renderDropdown();
663
+ });
664
+ monthWrapper.appendChild(monthSelect);
665
+ title.appendChild(monthWrapper);
666
+
667
+ nav.appendChild(title);
668
+
669
+ // Next button
670
+ const nextBtn = createElement('button', `${PREFIX}__nav-button`);
671
+ nextBtn.type = 'button';
672
+ nextBtn.innerHTML = '<i class="icon-expand-right"></i>';
673
+ if (isNextDisabled) nextBtn.disabled = true;
674
+ nextBtn.addEventListener('click', () => {
675
+ if (!isNextDisabled) {
676
+ onViewDateChange(new Date(year, month + 1, 1));
677
+ this.renderDropdown();
678
+ }
679
+ });
680
+ nav.appendChild(nextBtn);
681
+
682
+ return nav;
683
+ }
684
+
685
+ renderCalendarGrid(viewDate) {
686
+ const grid = createElement('div', `${PREFIX}__calendar-grid`);
687
+ const year = viewDate.getFullYear();
688
+ const month = viewDate.getMonth();
689
+ const today = new Date();
690
+
691
+ // Week days header
692
+ const headerRow = createElement('div', `${PREFIX}__calendar-row`);
693
+ this.texts.weekDays.forEach((day) => {
694
+ const cell = createElement('div', `${PREFIX}__calendar-cell ${PREFIX}__calendar-cell--header`, day);
695
+ headerRow.appendChild(cell);
696
+ });
697
+ grid.appendChild(headerRow);
698
+
699
+ // Days
700
+ const daysInMonth = getDaysInMonth(year, month);
701
+ const firstDay = getFirstDayOfMonth(year, month);
702
+ const prevMonthDays = getDaysInMonth(year, month - 1);
703
+
704
+ let days = [];
705
+
706
+ // Previous month days
707
+ for (let i = firstDay - 1; i >= 0; i--) {
708
+ const day = prevMonthDays - i;
709
+ const date = new Date(year, month - 1, day);
710
+ days.push({ day, date, isOther: true });
711
+ }
712
+
713
+ // Current month days
714
+ for (let day = 1; day <= daysInMonth; day++) {
715
+ const date = new Date(year, month, day);
716
+ days.push({ day, date, isOther: false });
717
+ }
718
+
719
+ // Next month days
720
+ const totalCells = Math.ceil((firstDay + daysInMonth) / 7) * 7;
721
+ const remainingDays = totalCells - (firstDay + daysInMonth);
722
+ for (let day = 1; day <= remainingDays; day++) {
723
+ const date = new Date(year, month + 1, day);
724
+ days.push({ day, date, isOther: true });
725
+ }
726
+
727
+ // Render rows
728
+ for (let i = 0; i < days.length; i += 7) {
729
+ const row = createElement('div', `${PREFIX}__calendar-row`);
730
+ for (let j = 0; j < 7 && i + j < days.length; j++) {
731
+ const { day, date, isOther } = days[i + j];
732
+ const cell = this.createDayCell(day, date, isOther, today);
733
+ row.appendChild(cell);
734
+ }
735
+ grid.appendChild(row);
736
+ }
737
+
738
+ return grid;
739
+ }
740
+
741
+ createDayCell(day, date, isOther, today) {
742
+ const cell = createElement('button', `${PREFIX}__calendar-cell`);
743
+ cell.type = 'button';
744
+ cell.textContent = day;
745
+
746
+ // Check if disabled
747
+ const isDisabled = this.checkDateDisabled(date);
748
+
749
+ if (isOther) cell.classList.add(`${PREFIX}__calendar-cell--other`);
750
+ if (isDisabled) {
751
+ cell.classList.add(`${PREFIX}__calendar-cell--disabled`);
752
+ cell.disabled = true;
753
+ }
754
+
755
+ // Check states
756
+ const isToday = isSameDay(date, today);
757
+ const isSelected = this.mode === 'instant' && isSameDay(date, this.tempValue.date);
758
+ const isRangeStart = this.mode === 'period' && isSameDay(date, this.tempValue.date);
759
+ const isRangeEnd = this.mode === 'period' && isSameDay(date, this.tempValue.endDate);
760
+ const isInRangeDay =
761
+ this.mode === 'period' &&
762
+ this.tempValue.date &&
763
+ this.tempValue.endDate &&
764
+ isInRangeExclusive(date, this.tempValue.date, this.tempValue.endDate);
765
+
766
+ if (isToday && !isSelected && !isRangeStart && !isRangeEnd) {
767
+ cell.classList.add(`${PREFIX}__calendar-cell--today`);
768
+ }
769
+ if (isSelected) cell.classList.add(`${PREFIX}__calendar-cell--selected`);
770
+ if (isRangeStart) cell.classList.add(`${PREFIX}__calendar-cell--range-start`);
771
+ if (isRangeEnd) cell.classList.add(`${PREFIX}__calendar-cell--range-end`);
772
+ if (isInRangeDay) cell.classList.add(`${PREFIX}__calendar-cell--in-range`);
773
+
774
+ if (!isDisabled) {
775
+ cell.addEventListener('click', () => this.handleDateSelect(date));
776
+ }
777
+
778
+ return cell;
779
+ }
780
+
781
+ checkDateDisabled(date) {
782
+ if (isDateDisabled(date, this.disable, this.enable)) return true;
783
+ if (isBeforeMinDate(date, this.minDate)) return true;
784
+ if (isAfterMaxDate(date, this.maxDate)) return true;
785
+ return false;
786
+ }
787
+
788
+ renderActions() {
789
+ const actions = createElement('div', `${PREFIX}__actions`);
790
+
791
+ // Period text
792
+ const periodText = createElement('span', `${PREFIX}__period-text`);
793
+ if (this.mode === 'period' && this.tempValue.date) {
794
+ periodText.textContent = this.formatPeriodText();
795
+ }
796
+ actions.appendChild(periodText);
797
+
798
+ // Buttons
799
+ const buttons = createElement('div', `${PREFIX}__action-buttons`);
800
+
801
+ // Reset button
802
+ const resetBtn = createElement('button', `${PREFIX}__action-button ${PREFIX}__action-button--reset`);
803
+ resetBtn.type = 'button';
804
+ resetBtn.innerHTML = `<i class="icon-refresh"></i>${this.texts.reset}`;
805
+ resetBtn.addEventListener('click', () => this.handleReset());
806
+ buttons.appendChild(resetBtn);
807
+
808
+ // Apply button
809
+ const applyBtn = createElement('button', `${PREFIX}__action-button ${PREFIX}__action-button--apply`);
810
+ applyBtn.type = 'button';
811
+ applyBtn.textContent = this.texts.apply;
812
+ applyBtn.addEventListener('click', () => this.handleApply());
813
+ buttons.appendChild(applyBtn);
814
+
815
+ actions.appendChild(buttons);
816
+ this.dropdownEl.appendChild(actions);
817
+ }
818
+
819
+ formatPeriodText() {
820
+ if (!this.tempValue.date) return '';
821
+
822
+ // format prop이 있으면 사용
823
+ if (this.format) {
824
+ const startText = formatWithPattern(this.tempValue.date, this.tempValue.time, this.format);
825
+ if (this.tempValue.endDate) {
826
+ const endText = formatWithPattern(this.tempValue.endDate, this.tempValue.endTime, this.format);
827
+ return `${startText} ~ ${endText}`;
828
+ }
829
+ return startText;
830
+ }
831
+
832
+ // 기본 포맷 (한국어)
833
+ const formatKoreanDateTime = (date, time) => {
834
+ const year = date.getFullYear();
835
+ const month = date.getMonth() + 1;
836
+ const day = date.getDate();
837
+ let dateStr = `${year}년 ${month}월 ${day}일`;
838
+ if (this.type === 'datetime' && time) {
839
+ const hours = String(time.hour).padStart(2, '0');
840
+ const minutes = String(time.minute).padStart(2, '0');
841
+ dateStr += ` ${hours}:${minutes}`;
842
+ }
843
+ return dateStr;
844
+ };
845
+
846
+ const startText = formatKoreanDateTime(this.tempValue.date, this.tempValue.time);
847
+ if (this.tempValue.endDate) {
848
+ const endText = formatKoreanDateTime(this.tempValue.endDate, this.tempValue.endTime);
849
+ return `${startText} ~ ${endText}`;
850
+ }
851
+ return startText;
852
+ }
853
+
854
+ // ============================================
855
+ // Event Handling
856
+ // ============================================
857
+
858
+ bindEvents() {
859
+ // Input click - open dropdown for date parts
860
+ this.inputEl.addEventListener('click', (e) => {
861
+ if (this.disabled) return;
862
+
863
+ const target = e.target;
864
+ if (target.dataset.part === 'date' || target.dataset.part === 'endDate') {
865
+ this.toggleDropdown(target.dataset.part);
866
+ }
867
+ });
868
+
869
+ // Time select changes
870
+ this.inputContentEl.addEventListener('change', (e) => {
871
+ if (e.target.tagName !== 'SELECT') return;
872
+
873
+ const part = e.target.dataset.part;
874
+ const value = parseInt(e.target.value);
875
+
876
+ this.handleTimeChange(part, value);
877
+ });
878
+
879
+ // Close on outside click
880
+ document.addEventListener('mousedown', (e) => {
881
+ if (!this.container.contains(e.target) && this.isOpen) {
882
+ this.close();
883
+ }
884
+ });
885
+ }
886
+
887
+ toggleDropdown(part) {
888
+ if (this.selectingPart === part && this.isOpen) {
889
+ this.close();
890
+ } else {
891
+ this.selectingPart = part;
892
+ this.open();
893
+ }
894
+ }
895
+
896
+ open() {
897
+ this.isOpen = true;
898
+ this.inputEl.classList.add(`${PREFIX}__input--active`);
899
+ this.dropdownEl.style.display = 'flex';
900
+ this.renderDropdown();
901
+ }
902
+
903
+ close() {
904
+ this.isOpen = false;
905
+ this.selectingPart = null;
906
+ this.inputEl.classList.remove(`${PREFIX}__input--active`);
907
+ this.dropdownEl.style.display = 'none';
908
+ }
909
+
910
+ handleViewDateChange(date) {
911
+ this.viewDate = date;
912
+ }
913
+
914
+ handleEndViewDateChange(date) {
915
+ this.endViewDate = date;
916
+ }
917
+
918
+ handleDateSelect(date) {
919
+ const newDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());
920
+
921
+ if (this.mode === 'instant') {
922
+ const adjustedTime = this.adjustTimeForDate(newDate, this.tempValue.time);
923
+ this.tempValue = { ...this.tempValue, date: newDate, time: adjustedTime };
924
+
925
+ if (!this.showActions) {
926
+ this.value = { ...this.tempValue };
927
+ this.emitChange();
928
+ }
929
+
930
+ this.close();
931
+ this.renderInputContent();
932
+ return;
933
+ }
934
+
935
+ // Period mode logic
936
+ const existingStartDate = this.tempValue.date;
937
+ const existingEndDate = this.tempValue.endDate;
938
+
939
+ if (!existingStartDate) {
940
+ const adjustedTime = this.adjustTimeForDate(newDate, this.tempValue.time);
941
+ this.tempValue = { ...this.tempValue, date: newDate, time: adjustedTime };
942
+ } else if (!existingEndDate) {
943
+ const dateOnly = new Date(newDate.getFullYear(), newDate.getMonth(), newDate.getDate());
944
+ const startDateOnly = new Date(
945
+ existingStartDate.getFullYear(),
946
+ existingStartDate.getMonth(),
947
+ existingStartDate.getDate()
948
+ );
949
+
950
+ if (dateOnly < startDateOnly) {
951
+ const adjustedStartTime = this.adjustTimeForDate(newDate, this.tempValue.time);
952
+ const adjustedEndTime = this.adjustTimeForDate(existingStartDate, this.tempValue.time);
953
+ this.tempValue = {
954
+ date: newDate,
955
+ time: adjustedStartTime,
956
+ endDate: existingStartDate,
957
+ endTime: adjustedEndTime,
958
+ };
959
+ } else {
960
+ const adjustedEndTime = this.adjustTimeForDate(newDate, this.tempValue.endTime);
961
+ this.tempValue = { ...this.tempValue, endDate: newDate, endTime: adjustedEndTime };
962
+ }
963
+ } else {
964
+ const adjustedTime = this.adjustTimeForDate(newDate, this.tempValue.time);
965
+ this.tempValue = { date: newDate, time: adjustedTime, endDate: undefined, endTime: undefined };
966
+ }
967
+
968
+ this.renderDropdown();
969
+ this.renderInputContent();
970
+ }
971
+
972
+ adjustTimeForDate(date, time) {
973
+ if (!time) return time;
974
+
975
+ const minLimit = this.minDate ? extractDateTimeLimit(this.minDate) : null;
976
+ const maxLimit = this.maxDate ? extractDateTimeLimit(this.maxDate) : null;
977
+
978
+ let adjustedHour = time.hour;
979
+ let adjustedMinute = time.minute;
980
+
981
+ if (minLimit?.time && isSameDay(date, minLimit.date)) {
982
+ if (adjustedHour < minLimit.time.hour) {
983
+ adjustedHour = minLimit.time.hour;
984
+ adjustedMinute = Math.ceil(minLimit.time.minute / this.minuteStep) * this.minuteStep;
985
+ } else if (adjustedHour === minLimit.time.hour && adjustedMinute < minLimit.time.minute) {
986
+ adjustedMinute = Math.ceil(minLimit.time.minute / this.minuteStep) * this.minuteStep;
987
+ }
988
+ }
989
+
990
+ if (maxLimit?.time && isSameDay(date, maxLimit.date)) {
991
+ if (adjustedHour > maxLimit.time.hour) {
992
+ adjustedHour = maxLimit.time.hour;
993
+ adjustedMinute = Math.floor(maxLimit.time.minute / this.minuteStep) * this.minuteStep;
994
+ } else if (adjustedHour === maxLimit.time.hour && adjustedMinute > maxLimit.time.minute) {
995
+ adjustedMinute = Math.floor(maxLimit.time.minute / this.minuteStep) * this.minuteStep;
996
+ }
997
+ }
998
+
999
+ if (adjustedHour !== time.hour || adjustedMinute !== time.minute) {
1000
+ return { hour: adjustedHour, minute: adjustedMinute };
1001
+ }
1002
+ return time;
1003
+ }
1004
+
1005
+ handleTimeChange(part, value) {
1006
+ const isEnd = part === 'endHour' || part === 'endMinute';
1007
+ const isHour = part === 'hour' || part === 'endHour';
1008
+
1009
+ const currentTime = isEnd ? this.tempValue.endTime : this.tempValue.time;
1010
+ let newTime = { hour: currentTime?.hour ?? 0, minute: currentTime?.minute ?? 0 };
1011
+
1012
+ if (isHour) {
1013
+ newTime.hour = value;
1014
+ // Auto-adjust minute if needed
1015
+ const currentDate = isEnd ? this.tempValue.endDate : this.tempValue.date;
1016
+ if (currentDate) {
1017
+ const minLimit = this.minDate ? extractDateTimeLimit(this.minDate) : null;
1018
+ const maxLimit = this.maxDate ? extractDateTimeLimit(this.maxDate) : null;
1019
+
1020
+ if (minLimit?.time && isSameDay(currentDate, minLimit.date) && value === minLimit.time.hour) {
1021
+ if (newTime.minute < minLimit.time.minute) {
1022
+ newTime.minute = Math.ceil(minLimit.time.minute / this.minuteStep) * this.minuteStep;
1023
+ }
1024
+ }
1025
+ if (maxLimit?.time && isSameDay(currentDate, maxLimit.date) && value === maxLimit.time.hour) {
1026
+ if (newTime.minute > maxLimit.time.minute) {
1027
+ newTime.minute = Math.floor(maxLimit.time.minute / this.minuteStep) * this.minuteStep;
1028
+ }
1029
+ }
1030
+ }
1031
+ } else {
1032
+ newTime.minute = value;
1033
+ }
1034
+
1035
+ if (isEnd) {
1036
+ this.tempValue = { ...this.tempValue, endTime: newTime };
1037
+ } else {
1038
+ this.tempValue = { ...this.tempValue, time: newTime };
1039
+ }
1040
+
1041
+ if (!this.showActions) {
1042
+ this.value = { ...this.tempValue };
1043
+ this.emitChange();
1044
+ }
1045
+
1046
+ this.renderInputContent();
1047
+ }
1048
+
1049
+ handleReset() {
1050
+ this.tempValue = {};
1051
+ this.close();
1052
+ this.renderInputContent();
1053
+ }
1054
+
1055
+ handleApply() {
1056
+ this.value = { ...this.tempValue };
1057
+ this.emitChange();
1058
+ this.close();
1059
+ this.renderInputContent();
1060
+ }
1061
+
1062
+ emitChange() {
1063
+ if (this.onChange) {
1064
+ this.onChange(this.value);
1065
+ }
1066
+ }
1067
+
1068
+ // ============================================
1069
+ // Public API
1070
+ // ============================================
1071
+
1072
+ /**
1073
+ * Get current value
1074
+ * @returns {Object} Current value
1075
+ */
1076
+ getValue() {
1077
+ return { ...this.value };
1078
+ }
1079
+
1080
+ /**
1081
+ * Set value programmatically
1082
+ * @param {Object} value - New value
1083
+ */
1084
+ setValue(value) {
1085
+ this.value = value || {};
1086
+ this.tempValue = { ...this.value };
1087
+ if (value?.date) {
1088
+ this.viewDate = new Date(value.date);
1089
+ }
1090
+ if (value?.endDate) {
1091
+ this.endViewDate = new Date(value.endDate.getFullYear(), value.endDate.getMonth() + 1, 1);
1092
+ }
1093
+ this.renderInputContent();
1094
+ }
1095
+
1096
+ /**
1097
+ * Clear value
1098
+ */
1099
+ clear() {
1100
+ this.value = {};
1101
+ this.tempValue = {};
1102
+ this.renderInputContent();
1103
+ this.emitChange();
1104
+ }
1105
+
1106
+ /**
1107
+ * Enable the datepicker
1108
+ */
1109
+ enable() {
1110
+ this.disabled = false;
1111
+ this.inputEl.classList.remove(`${PREFIX}__input--disabled`);
1112
+ this.renderInputContent();
1113
+ }
1114
+
1115
+ /**
1116
+ * Disable the datepicker
1117
+ */
1118
+ disable() {
1119
+ this.disabled = true;
1120
+ this.inputEl.classList.add(`${PREFIX}__input--disabled`);
1121
+ this.close();
1122
+ this.renderInputContent();
1123
+ }
1124
+
1125
+ /**
1126
+ * Destroy the datepicker instance
1127
+ */
1128
+ destroy() {
1129
+ this.container.innerHTML = '';
1130
+ }
1131
+ }
1132
+
1133
+ return PodoDatePicker;
1134
+ });