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