podo-ui 0.9.7 → 1.0.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.
Files changed (74) hide show
  1. package/cdn/podo-datepicker.css +1 -1
  2. package/cdn/podo-datepicker.js +1 -1
  3. package/cdn/podo-datepicker.min.css +1 -1
  4. package/cdn/podo-datepicker.min.js +1 -1
  5. package/cdn/podo-ui.css +4 -1
  6. package/cdn/podo-ui.min.css +1 -1
  7. package/dist/react/atom/editor.d.ts.map +1 -1
  8. package/dist/react/atom/editor.js +94 -2
  9. package/dist/svelte/actions/portal.d.ts +18 -0
  10. package/dist/svelte/actions/portal.js +42 -0
  11. package/dist/svelte/atom/Avatar.svelte +97 -0
  12. package/dist/svelte/atom/Avatar.svelte.d.ts +31 -0
  13. package/dist/svelte/atom/Button.svelte +86 -0
  14. package/dist/svelte/atom/Button.svelte.d.ts +26 -0
  15. package/dist/svelte/atom/Checkbox.svelte +56 -0
  16. package/dist/svelte/atom/Checkbox.svelte.d.ts +16 -0
  17. package/dist/svelte/atom/Chip.svelte +60 -0
  18. package/dist/svelte/atom/Chip.svelte.d.ts +25 -0
  19. package/dist/svelte/atom/Editor.svelte +1314 -0
  20. package/dist/svelte/atom/Editor.svelte.d.ts +30 -0
  21. package/dist/svelte/atom/EditorView.svelte +16 -0
  22. package/dist/svelte/atom/EditorView.svelte.d.ts +9 -0
  23. package/dist/svelte/atom/File.svelte +33 -0
  24. package/dist/svelte/atom/File.svelte.d.ts +14 -0
  25. package/dist/svelte/atom/Input.svelte +80 -0
  26. package/dist/svelte/atom/Input.svelte.d.ts +19 -0
  27. package/dist/svelte/atom/Label.svelte +43 -0
  28. package/dist/svelte/atom/Label.svelte.d.ts +19 -0
  29. package/dist/svelte/atom/Radio.svelte +69 -0
  30. package/dist/svelte/atom/Radio.svelte.d.ts +26 -0
  31. package/dist/svelte/atom/RadioGroup.svelte +46 -0
  32. package/dist/svelte/atom/RadioGroup.svelte.d.ts +16 -0
  33. package/dist/svelte/atom/Select.svelte +65 -0
  34. package/dist/svelte/atom/Select.svelte.d.ts +26 -0
  35. package/dist/svelte/atom/Textarea.svelte +53 -0
  36. package/dist/svelte/atom/Textarea.svelte.d.ts +13 -0
  37. package/dist/svelte/atom/Toggle.svelte +48 -0
  38. package/dist/svelte/atom/Toggle.svelte.d.ts +14 -0
  39. package/dist/svelte/atom/Tooltip.svelte +78 -0
  40. package/dist/svelte/atom/Tooltip.svelte.d.ts +23 -0
  41. package/dist/svelte/atom/avatar.module.scss +82 -0
  42. package/dist/svelte/atom/editor-view.module.scss +251 -0
  43. package/dist/svelte/atom/input.module.scss +98 -0
  44. package/dist/svelte/atom/textarea.module.scss +17 -0
  45. package/dist/svelte/atom/tooltip.module.scss +227 -0
  46. package/dist/svelte/index.d.ts +26 -0
  47. package/dist/svelte/index.js +30 -0
  48. package/dist/svelte/molecule/DatePicker.svelte +986 -0
  49. package/dist/svelte/molecule/DatePicker.svelte.d.ts +71 -0
  50. package/dist/svelte/molecule/Field.svelte +81 -0
  51. package/dist/svelte/molecule/Field.svelte.d.ts +26 -0
  52. package/dist/svelte/molecule/Pagination.svelte +95 -0
  53. package/dist/svelte/molecule/Pagination.svelte.d.ts +14 -0
  54. package/dist/svelte/molecule/Tab.svelte +69 -0
  55. package/dist/svelte/molecule/Tab.svelte.d.ts +26 -0
  56. package/dist/svelte/molecule/TabPanel.svelte +24 -0
  57. package/dist/svelte/molecule/TabPanel.svelte.d.ts +14 -0
  58. package/dist/svelte/molecule/Table.svelte +109 -0
  59. package/dist/svelte/molecule/Table.svelte.d.ts +54 -0
  60. package/dist/svelte/molecule/Toast.svelte +111 -0
  61. package/dist/svelte/molecule/Toast.svelte.d.ts +25 -0
  62. package/dist/svelte/molecule/ToastProvider.svelte +74 -0
  63. package/dist/svelte/molecule/ToastProvider.svelte.d.ts +8 -0
  64. package/dist/svelte/molecule/field.module.scss +22 -0
  65. package/dist/svelte/molecule/pagination.module.scss +61 -0
  66. package/dist/svelte/molecule/toast-container.module.scss +70 -0
  67. package/dist/svelte/molecule/toast.module.scss +12 -0
  68. package/dist/svelte/stores/toast.d.ts +45 -0
  69. package/dist/svelte/stores/toast.js +55 -0
  70. package/dist/svelte/stores/validation.d.ts +15 -0
  71. package/dist/svelte/stores/validation.js +38 -0
  72. package/global.scss +1 -0
  73. package/package.json +32 -5
  74. package/vite-fonts.scss +1 -1
@@ -0,0 +1,986 @@
1
+ <script lang="ts">
2
+ import { onMount, onDestroy } from 'svelte';
3
+ import styles from '../../react/molecule/datepicker.module.scss';
4
+
5
+ // Types
6
+ export type DatePickerMode = 'instant' | 'period';
7
+ export type DatePickerType = 'date' | 'time' | 'datetime';
8
+
9
+ export interface TimeValue {
10
+ hour: number;
11
+ minute: number;
12
+ }
13
+
14
+ export interface DatePickerValue {
15
+ date?: Date;
16
+ time?: TimeValue;
17
+ endDate?: Date;
18
+ endTime?: TimeValue;
19
+ }
20
+
21
+ export interface DateRange {
22
+ from: Date;
23
+ to: Date;
24
+ }
25
+
26
+ export type DateCondition = Date | DateRange | ((date: Date) => boolean);
27
+
28
+ export interface DateTimeLimit {
29
+ date: Date;
30
+ time?: TimeValue;
31
+ }
32
+
33
+ export type MinuteStep = 1 | 5 | 10 | 15 | 20 | 30;
34
+
35
+ export interface YearRange {
36
+ min?: number;
37
+ max?: number;
38
+ }
39
+
40
+ export type CalendarInitial = 'now' | 'prevMonth' | 'nextMonth' | Date;
41
+
42
+ export interface InitialCalendar {
43
+ start?: CalendarInitial;
44
+ end?: CalendarInitial;
45
+ }
46
+
47
+ interface Props {
48
+ /** Selection mode: instant | period */
49
+ mode?: DatePickerMode;
50
+ /** Value type: date | time | datetime */
51
+ type?: DatePickerType;
52
+ /** Selected value */
53
+ value?: DatePickerValue;
54
+ /** Value change handler */
55
+ onchange?: (value: DatePickerValue) => void;
56
+ /** Placeholder */
57
+ placeholder?: string;
58
+ /** Disabled state */
59
+ disabled?: boolean;
60
+ /** Show action buttons (default true for period mode) */
61
+ showActions?: boolean;
62
+ /** Dropdown alignment */
63
+ align?: 'left' | 'right';
64
+ /** Additional class name */
65
+ class?: string;
66
+ /** Disabled date conditions */
67
+ disable?: DateCondition[];
68
+ /** Enabled date conditions (only these dates are selectable) */
69
+ enable?: DateCondition[];
70
+ /** Minimum selectable date */
71
+ minDate?: Date | DateTimeLimit;
72
+ /** Maximum selectable date */
73
+ maxDate?: Date | DateTimeLimit;
74
+ /** Minute selection step */
75
+ minuteStep?: MinuteStep;
76
+ /** Date/time format pattern */
77
+ format?: string;
78
+ /** Initial calendar display month for period mode */
79
+ initialCalendar?: InitialCalendar;
80
+ /** Year selection range */
81
+ yearRange?: YearRange;
82
+ }
83
+
84
+ let {
85
+ mode = 'instant',
86
+ type = 'date',
87
+ value = $bindable<DatePickerValue>({}),
88
+ onchange,
89
+ placeholder,
90
+ disabled = false,
91
+ showActions,
92
+ align = 'left',
93
+ class: className = '',
94
+ disable,
95
+ enable,
96
+ minDate,
97
+ maxDate,
98
+ minuteStep = 1,
99
+ format,
100
+ initialCalendar,
101
+ yearRange,
102
+ ...rest
103
+ }: Props & Record<string, unknown> = $props();
104
+
105
+ // State
106
+ type SelectingPart = 'date' | 'hour' | 'minute' | 'endDate' | 'endHour' | 'endMinute' | null;
107
+
108
+ let selectingPart = $state<SelectingPart>(null);
109
+ let tempValue = $state<DatePickerValue>(value || {});
110
+ let viewDate = $state(new Date());
111
+ let endViewDate = $state(new Date(new Date().getFullYear(), new Date().getMonth() + 1, 1));
112
+ let containerRef = $state<HTMLDivElement | null>(null);
113
+
114
+ const today = new Date();
115
+
116
+ // Computed
117
+ const shouldShowActions = $derived(showActions ?? mode === 'period');
118
+ const isOpen = $derived(selectingPart === 'date' || selectingPart === 'endDate');
119
+ const displayValue = $derived(shouldShowActions ? tempValue : value);
120
+ const hasStartValue = $derived(!!displayValue?.date);
121
+ const hasEndValue = $derived(!!displayValue?.endDate);
122
+ const inputIcon = $derived(type === 'time' ? 'icon-time' : 'icon-calendar');
123
+
124
+ // Helper functions
125
+ const resolveCalendarInitial = (initial: CalendarInitial | undefined, fallback: Date): Date => {
126
+ if (!initial) return fallback;
127
+ if (initial instanceof Date) return initial;
128
+
129
+ const now = new Date();
130
+ switch (initial) {
131
+ case 'now':
132
+ return new Date(now.getFullYear(), now.getMonth(), 1);
133
+ case 'prevMonth':
134
+ return new Date(now.getFullYear(), now.getMonth() - 1, 1);
135
+ case 'nextMonth':
136
+ return new Date(now.getFullYear(), now.getMonth() + 1, 1);
137
+ default:
138
+ return fallback;
139
+ }
140
+ };
141
+
142
+ const formatWithPattern = (date: Date | undefined, time: TimeValue | undefined, pattern: string): string => {
143
+ if (!date && !time) return '';
144
+
145
+ let result = pattern;
146
+
147
+ if (date) {
148
+ result = result.replace(/y/g, String(date.getFullYear()));
149
+ result = result.replace(/m/g, String(date.getMonth() + 1).padStart(2, '0'));
150
+ result = result.replace(/d/g, String(date.getDate()).padStart(2, '0'));
151
+ }
152
+
153
+ if (time) {
154
+ result = result.replace(/h/g, String(time.hour).padStart(2, '0'));
155
+ result = result.replace(/i/g, String(time.minute).padStart(2, '0'));
156
+ }
157
+
158
+ return result;
159
+ };
160
+
161
+ const isSameDay = (date1: Date, date2: Date): boolean => {
162
+ return (
163
+ date1.getFullYear() === date2.getFullYear() &&
164
+ date1.getMonth() === date2.getMonth() &&
165
+ date1.getDate() === date2.getDate()
166
+ );
167
+ };
168
+
169
+ const isInRange = (date: Date, start: Date, end: Date): boolean => {
170
+ const d = new Date(date.getFullYear(), date.getMonth(), date.getDate());
171
+ const s = new Date(start.getFullYear(), start.getMonth(), start.getDate());
172
+ const e = new Date(end.getFullYear(), end.getMonth(), end.getDate());
173
+ return d >= s && d <= e;
174
+ };
175
+
176
+ const isInRangeExclusive = (date: Date, start: Date, end: Date): boolean => {
177
+ const d = new Date(date.getFullYear(), date.getMonth(), date.getDate());
178
+ const s = new Date(start.getFullYear(), start.getMonth(), start.getDate());
179
+ const e = new Date(end.getFullYear(), end.getMonth(), end.getDate());
180
+ return d > s && d < e;
181
+ };
182
+
183
+ const getDaysInMonth = (year: number, month: number): number => {
184
+ return new Date(year, month + 1, 0).getDate();
185
+ };
186
+
187
+ const getFirstDayOfMonth = (year: number, month: number): number => {
188
+ return new Date(year, month, 1).getDay();
189
+ };
190
+
191
+ const isDateRange = (condition: DateCondition): condition is DateRange => {
192
+ return typeof condition === 'object' && 'from' in condition && 'to' in condition;
193
+ };
194
+
195
+ const matchesCondition = (date: Date, condition: DateCondition): boolean => {
196
+ if (typeof condition === 'function') {
197
+ return condition(date);
198
+ }
199
+ if (isDateRange(condition)) {
200
+ return isInRange(date, condition.from, condition.to);
201
+ }
202
+ return isSameDay(date, condition);
203
+ };
204
+
205
+ const isDateDisabled = (date: Date): boolean => {
206
+ if (enable && enable.length > 0) {
207
+ const isEnabled = enable.some((condition) => matchesCondition(date, condition));
208
+ return !isEnabled;
209
+ }
210
+
211
+ if (disable && disable.length > 0) {
212
+ return disable.some((condition) => matchesCondition(date, condition));
213
+ }
214
+
215
+ return false;
216
+ };
217
+
218
+ const isDateTimeLimit = (val: Date | DateTimeLimit): val is DateTimeLimit => {
219
+ return typeof val === 'object' && 'date' in val && !(val instanceof Date);
220
+ };
221
+
222
+ const extractDateTimeLimit = (limit: Date | DateTimeLimit): { date: Date; time?: TimeValue } => {
223
+ if (isDateTimeLimit(limit)) {
224
+ return { date: limit.date, time: limit.time };
225
+ }
226
+ return { date: limit };
227
+ };
228
+
229
+ const isBeforeMinDate = (date: Date): boolean => {
230
+ if (!minDate) return false;
231
+ const { date: minDateValue } = extractDateTimeLimit(minDate);
232
+ const d = new Date(date.getFullYear(), date.getMonth(), date.getDate());
233
+ const m = new Date(minDateValue.getFullYear(), minDateValue.getMonth(), minDateValue.getDate());
234
+ return d < m;
235
+ };
236
+
237
+ const isAfterMaxDate = (date: Date): boolean => {
238
+ if (!maxDate) return false;
239
+ const { date: maxDateValue } = extractDateTimeLimit(maxDate);
240
+ const d = new Date(date.getFullYear(), date.getMonth(), date.getDate());
241
+ const m = new Date(maxDateValue.getFullYear(), maxDateValue.getMonth(), maxDateValue.getDate());
242
+ return d > m;
243
+ };
244
+
245
+ const checkDateDisabled = (date: Date): boolean => {
246
+ if (isDateDisabled(date)) return true;
247
+ if (isBeforeMinDate(date)) return true;
248
+ if (isAfterMaxDate(date)) return true;
249
+ return false;
250
+ };
251
+
252
+ // Calendar generation
253
+ const weekDays = ['일', '월', '화', '수', '목', '금', '토'];
254
+
255
+ const calculateYearBounds = () => {
256
+ const currentYear = today.getFullYear();
257
+ let minYearBound = currentYear - 100;
258
+ let maxYearBound = currentYear + 100;
259
+
260
+ if (minDate) {
261
+ const { date } = extractDateTimeLimit(minDate);
262
+ minYearBound = Math.max(minYearBound, date.getFullYear());
263
+ }
264
+ if (maxDate) {
265
+ const { date } = extractDateTimeLimit(maxDate);
266
+ maxYearBound = Math.min(maxYearBound, date.getFullYear());
267
+ }
268
+
269
+ if (yearRange?.min !== undefined) minYearBound = yearRange.min;
270
+ if (yearRange?.max !== undefined) maxYearBound = yearRange.max;
271
+
272
+ return { minYearBound, maxYearBound };
273
+ };
274
+
275
+ const { minYearBound, maxYearBound } = calculateYearBounds();
276
+ const yearOptions = Array.from(
277
+ { length: Math.max(0, maxYearBound - minYearBound + 1) },
278
+ (_, i) => minYearBound + i
279
+ );
280
+
281
+ const monthOptions = Array.from({ length: 12 }, (_, i) => i);
282
+
283
+ const generateCalendarDays = (vDate: Date) => {
284
+ const year = vDate.getFullYear();
285
+ const month = vDate.getMonth();
286
+ const daysInMonth = getDaysInMonth(year, month);
287
+ const firstDay = getFirstDayOfMonth(year, month);
288
+ const prevMonthDays = getDaysInMonth(year, month - 1);
289
+
290
+ const days: Array<{ day: number; date: Date; isOther: boolean; isDisabled: boolean }> = [];
291
+
292
+ // Previous month days
293
+ for (let i = firstDay - 1; i >= 0; i--) {
294
+ const day = prevMonthDays - i;
295
+ const date = new Date(year, month - 1, day);
296
+ days.push({ day, date, isOther: true, isDisabled: checkDateDisabled(date) });
297
+ }
298
+
299
+ // Current month days
300
+ for (let day = 1; day <= daysInMonth; day++) {
301
+ const date = new Date(year, month, day);
302
+ days.push({ day, date, isOther: false, isDisabled: checkDateDisabled(date) });
303
+ }
304
+
305
+ // Next month days
306
+ const totalCells = Math.ceil((firstDay + daysInMonth) / 7) * 7;
307
+ const remainingDays = totalCells - (firstDay + daysInMonth);
308
+ for (let day = 1; day <= remainingDays; day++) {
309
+ const date = new Date(year, month + 1, day);
310
+ days.push({ day, date, isOther: true, isDisabled: checkDateDisabled(date) });
311
+ }
312
+
313
+ // Group into weeks
314
+ const weeks: typeof days[] = [];
315
+ for (let i = 0; i < days.length; i += 7) {
316
+ weeks.push(days.slice(i, i + 7));
317
+ }
318
+
319
+ return weeks;
320
+ };
321
+
322
+ // Format display
323
+ const formatPeriodText = () => {
324
+ if (!tempValue.date) return '';
325
+
326
+ if (format) {
327
+ const startText = formatWithPattern(tempValue.date, tempValue.time, format);
328
+ if (tempValue.endDate) {
329
+ const endText = formatWithPattern(tempValue.endDate, tempValue.endTime, format);
330
+ return `${startText} ~ ${endText}`;
331
+ }
332
+ return startText;
333
+ }
334
+
335
+ const formatKoreanDateTime = (date: Date, time?: TimeValue) => {
336
+ const year = date.getFullYear();
337
+ const month = date.getMonth() + 1;
338
+ const day = date.getDate();
339
+ const dateStr = `${year}년 ${month}월 ${day}일`;
340
+ if (type === 'datetime' && time) {
341
+ const hours = String(time.hour).padStart(2, '0');
342
+ const minutes = String(time.minute).padStart(2, '0');
343
+ return `${dateStr} ${hours}:${minutes}`;
344
+ }
345
+ return dateStr;
346
+ };
347
+
348
+ const startText = formatKoreanDateTime(tempValue.date, tempValue.time);
349
+ if (tempValue.endDate) {
350
+ const endText = formatKoreanDateTime(tempValue.endDate, tempValue.endTime);
351
+ return `${startText} ~ ${endText}`;
352
+ }
353
+ return startText;
354
+ };
355
+
356
+ const getDateOnlyFormat = (): string | undefined => {
357
+ if (!format) return undefined;
358
+ return format.replace(/\s*h[:\s]*i[분]?/g, '').replace(/\s*h시\s*i분/g, '').trim();
359
+ };
360
+
361
+ const formatDateDisplay = (date: Date | undefined): string => {
362
+ if (!date) {
363
+ const dateFormat = getDateOnlyFormat();
364
+ return dateFormat
365
+ ? dateFormat.replace(/y/g, 'YYYY').replace(/m/g, 'MM').replace(/d/g, 'DD')
366
+ : 'YYYY - MM - DD';
367
+ }
368
+
369
+ const dateFormat = getDateOnlyFormat();
370
+ return dateFormat
371
+ ? formatWithPattern(date, undefined, dateFormat)
372
+ : `${date.getFullYear()} - ${String(date.getMonth() + 1).padStart(2, '0')} - ${String(date.getDate()).padStart(2, '0')}`;
373
+ };
374
+
375
+ // Event handlers
376
+ const handleClickOutside = (event: MouseEvent) => {
377
+ if (containerRef && !containerRef.contains(event.target as Node)) {
378
+ selectingPart = null;
379
+ }
380
+ };
381
+
382
+ const handlePartClick = (part: SelectingPart) => {
383
+ if (disabled) return;
384
+ selectingPart = selectingPart === part ? null : part;
385
+ };
386
+
387
+ const adjustTimeForDate = (date: Date, time: TimeValue | undefined): TimeValue | undefined => {
388
+ if (!time) return time;
389
+
390
+ const minLimit = minDate ? extractDateTimeLimit(minDate) : null;
391
+ const maxLimit = maxDate ? extractDateTimeLimit(maxDate) : null;
392
+
393
+ let adjustedHour = time.hour;
394
+ let adjustedMinute = time.minute;
395
+
396
+ if (minLimit?.time && isSameDay(date, minLimit.date)) {
397
+ if (adjustedHour < minLimit.time.hour) {
398
+ adjustedHour = minLimit.time.hour;
399
+ adjustedMinute = Math.ceil(minLimit.time.minute / minuteStep) * minuteStep;
400
+ } else if (adjustedHour === minLimit.time.hour && adjustedMinute < minLimit.time.minute) {
401
+ adjustedMinute = Math.ceil(minLimit.time.minute / minuteStep) * minuteStep;
402
+ }
403
+ }
404
+
405
+ if (maxLimit?.time && isSameDay(date, maxLimit.date)) {
406
+ if (adjustedHour > maxLimit.time.hour) {
407
+ adjustedHour = maxLimit.time.hour;
408
+ adjustedMinute = Math.floor(maxLimit.time.minute / minuteStep) * minuteStep;
409
+ } else if (adjustedHour === maxLimit.time.hour && adjustedMinute > maxLimit.time.minute) {
410
+ adjustedMinute = Math.floor(maxLimit.time.minute / minuteStep) * minuteStep;
411
+ }
412
+ }
413
+
414
+ if (adjustedHour !== time.hour || adjustedMinute !== time.minute) {
415
+ return { hour: adjustedHour, minute: adjustedMinute };
416
+ }
417
+ return time;
418
+ };
419
+
420
+ const handleDateSelect = (date: Date) => {
421
+ const newDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());
422
+
423
+ if (mode === 'instant') {
424
+ const adjustedTime = adjustTimeForDate(newDate, tempValue.time);
425
+ const newValue = { ...tempValue, date: newDate, time: adjustedTime };
426
+ tempValue = newValue;
427
+ if (!shouldShowActions) {
428
+ value = newValue;
429
+ onchange?.(newValue);
430
+ }
431
+ selectingPart = null;
432
+ return;
433
+ }
434
+
435
+ // Period mode
436
+ const existingStartDate = tempValue.date;
437
+ const existingEndDate = tempValue.endDate;
438
+
439
+ if (!existingStartDate) {
440
+ const adjustedTime = adjustTimeForDate(newDate, tempValue.time);
441
+ tempValue = { ...tempValue, date: newDate, time: adjustedTime };
442
+ return;
443
+ }
444
+
445
+ if (!existingEndDate) {
446
+ const dateOnly = new Date(newDate.getFullYear(), newDate.getMonth(), newDate.getDate());
447
+ const startDateOnly = new Date(existingStartDate.getFullYear(), existingStartDate.getMonth(), existingStartDate.getDate());
448
+
449
+ if (dateOnly < startDateOnly) {
450
+ const adjustedStartTime = adjustTimeForDate(newDate, tempValue.time);
451
+ const adjustedEndTime = adjustTimeForDate(existingStartDate, tempValue.time);
452
+ tempValue = {
453
+ date: newDate,
454
+ time: adjustedStartTime,
455
+ endDate: existingStartDate,
456
+ endTime: adjustedEndTime,
457
+ };
458
+ } else {
459
+ const adjustedEndTime = adjustTimeForDate(newDate, tempValue.endTime);
460
+ tempValue = { ...tempValue, endDate: newDate, endTime: adjustedEndTime };
461
+ }
462
+ return;
463
+ }
464
+
465
+ // Reset when both are selected
466
+ const adjustedTime = adjustTimeForDate(newDate, tempValue.time);
467
+ tempValue = { date: newDate, time: adjustedTime, endDate: undefined, endTime: undefined };
468
+ };
469
+
470
+ const handleReset = () => {
471
+ tempValue = {};
472
+ selectingPart = null;
473
+ };
474
+
475
+ const handleApply = () => {
476
+ value = tempValue;
477
+ onchange?.(tempValue);
478
+ selectingPart = null;
479
+ };
480
+
481
+ const handleYearChange = (e: Event, isEnd = false) => {
482
+ const target = e.target as HTMLSelectElement;
483
+ const newYear = parseInt(target.value);
484
+ if (isEnd) {
485
+ endViewDate = new Date(newYear, endViewDate.getMonth(), 1);
486
+ } else {
487
+ viewDate = new Date(newYear, viewDate.getMonth(), 1);
488
+ }
489
+ };
490
+
491
+ const handleMonthChange = (e: Event, isEnd = false) => {
492
+ const target = e.target as HTMLSelectElement;
493
+ const newMonth = parseInt(target.value);
494
+ if (isEnd) {
495
+ endViewDate = new Date(endViewDate.getFullYear(), newMonth, 1);
496
+ } else {
497
+ viewDate = new Date(viewDate.getFullYear(), newMonth, 1);
498
+ }
499
+ };
500
+
501
+ const handlePrevMonth = (isEnd = false) => {
502
+ if (isEnd) {
503
+ endViewDate = new Date(endViewDate.getFullYear(), endViewDate.getMonth() - 1, 1);
504
+ } else {
505
+ viewDate = new Date(viewDate.getFullYear(), viewDate.getMonth() - 1, 1);
506
+ }
507
+ };
508
+
509
+ const handleNextMonth = (isEnd = false) => {
510
+ if (isEnd) {
511
+ endViewDate = new Date(endViewDate.getFullYear(), endViewDate.getMonth() + 1, 1);
512
+ } else {
513
+ viewDate = new Date(viewDate.getFullYear(), viewDate.getMonth() + 1, 1);
514
+ }
515
+ };
516
+
517
+ const handleHourChange = (e: Event, isEnd = false) => {
518
+ const target = e.target as HTMLSelectElement;
519
+ const hour = parseInt(target.value);
520
+ const currentTime = isEnd ? tempValue.endTime : tempValue.time;
521
+ const newTime: TimeValue = { hour, minute: currentTime?.minute ?? 0 };
522
+
523
+ const newValue = isEnd
524
+ ? { ...tempValue, endTime: newTime }
525
+ : { ...tempValue, time: newTime };
526
+ tempValue = newValue;
527
+ if (!shouldShowActions) {
528
+ value = newValue;
529
+ onchange?.(newValue);
530
+ }
531
+ };
532
+
533
+ const handleMinuteChange = (e: Event, isEnd = false) => {
534
+ const target = e.target as HTMLSelectElement;
535
+ const minute = parseInt(target.value);
536
+ const currentTime = isEnd ? tempValue.endTime : tempValue.time;
537
+ const newTime: TimeValue = { hour: currentTime?.hour ?? 0, minute };
538
+
539
+ const newValue = isEnd
540
+ ? { ...tempValue, endTime: newTime }
541
+ : { ...tempValue, time: newTime };
542
+ tempValue = newValue;
543
+ if (!shouldShowActions) {
544
+ value = newValue;
545
+ onchange?.(newValue);
546
+ }
547
+ };
548
+
549
+ // Time select options
550
+ const hours = Array.from({ length: 24 }, (_, i) => i);
551
+ const minutes = Array.from({ length: Math.ceil(60 / minuteStep) }, (_, i) => i * minuteStep);
552
+
553
+ // Calendar cell class computation
554
+ const getCellClass = (dayInfo: { day: number; date: Date; isOther: boolean; isDisabled: boolean }) => {
555
+ const { date, isOther, isDisabled } = dayInfo;
556
+ const classes = [styles.calendarCell];
557
+
558
+ if (isDisabled) classes.push(styles.disabled);
559
+ if (isOther) classes.push(styles.other);
560
+
561
+ const isToday = isSameDay(date, today);
562
+ const isSelected = tempValue.date && isSameDay(date, tempValue.date);
563
+ const isRangeStart = mode === 'period' && tempValue.date && isSameDay(date, tempValue.date);
564
+ const isRangeEnd = mode === 'period' && tempValue.endDate && isSameDay(date, tempValue.endDate);
565
+ const isInRangeDay = mode === 'period' && tempValue.date && tempValue.endDate &&
566
+ isInRangeExclusive(date, tempValue.date, tempValue.endDate);
567
+
568
+ if (isToday && !isSelected && !isRangeStart && !isRangeEnd) classes.push(styles.today);
569
+ if (mode === 'instant' && isSelected) classes.push(styles.selected);
570
+ if (isRangeStart) classes.push(styles.rangeStart);
571
+ if (isRangeEnd) classes.push(styles.rangeEnd);
572
+ if (isInRangeDay) classes.push(styles.inRange);
573
+
574
+ return classes.join(' ');
575
+ };
576
+
577
+ // Initialize
578
+ $effect(() => {
579
+ tempValue = value || {};
580
+ });
581
+
582
+ $effect(() => {
583
+ if (value?.date) {
584
+ viewDate = value.date;
585
+ } else if (initialCalendar?.start) {
586
+ viewDate = resolveCalendarInitial(initialCalendar.start, new Date());
587
+ }
588
+
589
+ if (value?.endDate) {
590
+ endViewDate = new Date(value.endDate.getFullYear(), value.endDate.getMonth() + 1, 1);
591
+ } else if (initialCalendar?.end) {
592
+ endViewDate = resolveCalendarInitial(initialCalendar.end, new Date());
593
+ }
594
+ });
595
+
596
+ onMount(() => {
597
+ if (typeof document !== 'undefined') {
598
+ document.addEventListener('mousedown', handleClickOutside);
599
+ }
600
+ });
601
+
602
+ onDestroy(() => {
603
+ if (typeof document !== 'undefined') {
604
+ document.removeEventListener('mousedown', handleClickOutside);
605
+ }
606
+ });
607
+ </script>
608
+
609
+ <div bind:this={containerRef} class="{styles.datepicker} {className}" {...rest}>
610
+ <div class="{styles.input} {isOpen ? styles.active : ''} {disabled ? styles.disabled : ''}">
611
+ <div class={styles.inputContent}>
612
+ {#if type === 'date'}
613
+ {#if mode === 'period'}
614
+ <button
615
+ type="button"
616
+ class="{styles.inputPart} {selectingPart === 'date' ? styles.active : ''} {!hasStartValue ? styles.placeholder : ''}"
617
+ onclick={() => handlePartClick('date')}
618
+ >
619
+ {formatDateDisplay(displayValue?.date)}
620
+ </button>
621
+ <span class={styles.separator}>~</span>
622
+ <button
623
+ type="button"
624
+ class="{styles.inputPart} {selectingPart === 'endDate' ? styles.active : ''} {!hasEndValue ? styles.placeholder : ''}"
625
+ onclick={() => handlePartClick('endDate')}
626
+ >
627
+ {formatDateDisplay(displayValue?.endDate)}
628
+ </button>
629
+ {:else}
630
+ <button
631
+ type="button"
632
+ class="{styles.inputPart} {selectingPart === 'date' ? styles.active : ''} {!hasStartValue ? styles.placeholder : ''}"
633
+ onclick={() => handlePartClick('date')}
634
+ >
635
+ {formatDateDisplay(displayValue?.date)}
636
+ </button>
637
+ {/if}
638
+ {:else if type === 'time'}
639
+ {#if mode === 'period'}
640
+ <div class={styles.timeSection}>
641
+ <select
642
+ class="{styles.timeSelect} {!displayValue?.time ? styles.placeholder : ''}"
643
+ value={displayValue?.time?.hour ?? 0}
644
+ onchange={(e) => handleHourChange(e, false)}
645
+ {disabled}
646
+ >
647
+ {#each hours as h}
648
+ <option value={h}>{String(h).padStart(2, '0')}</option>
649
+ {/each}
650
+ </select>
651
+ <span class={styles.timeSeparator}>:</span>
652
+ <select
653
+ class="{styles.timeSelect} {!displayValue?.time ? styles.placeholder : ''}"
654
+ value={displayValue?.time?.minute ?? 0}
655
+ onchange={(e) => handleMinuteChange(e, false)}
656
+ {disabled}
657
+ >
658
+ {#each minutes as m}
659
+ <option value={m}>{String(m).padStart(2, '0')}</option>
660
+ {/each}
661
+ </select>
662
+ </div>
663
+ <span class={styles.separator}>~</span>
664
+ <div class={styles.timeSection}>
665
+ <select
666
+ class="{styles.timeSelect} {!displayValue?.endTime ? styles.placeholder : ''}"
667
+ value={displayValue?.endTime?.hour ?? 0}
668
+ onchange={(e) => handleHourChange(e, true)}
669
+ {disabled}
670
+ >
671
+ {#each hours as h}
672
+ <option value={h}>{String(h).padStart(2, '0')}</option>
673
+ {/each}
674
+ </select>
675
+ <span class={styles.timeSeparator}>:</span>
676
+ <select
677
+ class="{styles.timeSelect} {!displayValue?.endTime ? styles.placeholder : ''}"
678
+ value={displayValue?.endTime?.minute ?? 0}
679
+ onchange={(e) => handleMinuteChange(e, true)}
680
+ {disabled}
681
+ >
682
+ {#each minutes as m}
683
+ <option value={m}>{String(m).padStart(2, '0')}</option>
684
+ {/each}
685
+ </select>
686
+ </div>
687
+ {:else}
688
+ <div class={styles.timeSection}>
689
+ <select
690
+ class="{styles.timeSelect} {!displayValue?.time ? styles.placeholder : ''}"
691
+ value={displayValue?.time?.hour ?? 0}
692
+ onchange={(e) => handleHourChange(e, false)}
693
+ {disabled}
694
+ >
695
+ {#each hours as h}
696
+ <option value={h}>{String(h).padStart(2, '0')}</option>
697
+ {/each}
698
+ </select>
699
+ <span class={styles.timeSeparator}>:</span>
700
+ <select
701
+ class="{styles.timeSelect} {!displayValue?.time ? styles.placeholder : ''}"
702
+ value={displayValue?.time?.minute ?? 0}
703
+ onchange={(e) => handleMinuteChange(e, false)}
704
+ {disabled}
705
+ >
706
+ {#each minutes as m}
707
+ <option value={m}>{String(m).padStart(2, '0')}</option>
708
+ {/each}
709
+ </select>
710
+ </div>
711
+ {/if}
712
+ {:else}
713
+ <!-- datetime -->
714
+ {#if mode === 'period'}
715
+ <button
716
+ type="button"
717
+ class="{styles.inputPart} {selectingPart === 'date' ? styles.active : ''} {!hasStartValue ? styles.placeholder : ''}"
718
+ onclick={() => handlePartClick('date')}
719
+ >
720
+ {formatDateDisplay(displayValue?.date)}
721
+ </button>
722
+ <div class={styles.timeSection}>
723
+ <select
724
+ class="{styles.timeSelect} {!displayValue?.time ? styles.placeholder : ''}"
725
+ value={displayValue?.time?.hour ?? 0}
726
+ onchange={(e) => handleHourChange(e, false)}
727
+ {disabled}
728
+ >
729
+ {#each hours as h}
730
+ <option value={h}>{String(h).padStart(2, '0')}</option>
731
+ {/each}
732
+ </select>
733
+ <span class={styles.timeSeparator}>:</span>
734
+ <select
735
+ class="{styles.timeSelect} {!displayValue?.time ? styles.placeholder : ''}"
736
+ value={displayValue?.time?.minute ?? 0}
737
+ onchange={(e) => handleMinuteChange(e, false)}
738
+ {disabled}
739
+ >
740
+ {#each minutes as m}
741
+ <option value={m}>{String(m).padStart(2, '0')}</option>
742
+ {/each}
743
+ </select>
744
+ </div>
745
+ <span class={styles.separator}>~</span>
746
+ <button
747
+ type="button"
748
+ class="{styles.inputPart} {selectingPart === 'endDate' ? styles.active : ''} {!hasEndValue ? styles.placeholder : ''}"
749
+ onclick={() => handlePartClick('endDate')}
750
+ >
751
+ {formatDateDisplay(displayValue?.endDate)}
752
+ </button>
753
+ <div class={styles.timeSection}>
754
+ <select
755
+ class="{styles.timeSelect} {!displayValue?.endTime ? styles.placeholder : ''}"
756
+ value={displayValue?.endTime?.hour ?? 0}
757
+ onchange={(e) => handleHourChange(e, true)}
758
+ {disabled}
759
+ >
760
+ {#each hours as h}
761
+ <option value={h}>{String(h).padStart(2, '0')}</option>
762
+ {/each}
763
+ </select>
764
+ <span class={styles.timeSeparator}>:</span>
765
+ <select
766
+ class="{styles.timeSelect} {!displayValue?.endTime ? styles.placeholder : ''}"
767
+ value={displayValue?.endTime?.minute ?? 0}
768
+ onchange={(e) => handleMinuteChange(e, true)}
769
+ {disabled}
770
+ >
771
+ {#each minutes as m}
772
+ <option value={m}>{String(m).padStart(2, '0')}</option>
773
+ {/each}
774
+ </select>
775
+ </div>
776
+ {:else}
777
+ <button
778
+ type="button"
779
+ class="{styles.inputPart} {selectingPart === 'date' ? styles.active : ''} {!hasStartValue ? styles.placeholder : ''}"
780
+ onclick={() => handlePartClick('date')}
781
+ >
782
+ {formatDateDisplay(displayValue?.date)}
783
+ </button>
784
+ <div class={styles.timeSection}>
785
+ <select
786
+ class="{styles.timeSelect} {!displayValue?.time ? styles.placeholder : ''}"
787
+ value={displayValue?.time?.hour ?? 0}
788
+ onchange={(e) => handleHourChange(e, false)}
789
+ {disabled}
790
+ >
791
+ {#each hours as h}
792
+ <option value={h}>{String(h).padStart(2, '0')}</option>
793
+ {/each}
794
+ </select>
795
+ <span class={styles.timeSeparator}>:</span>
796
+ <select
797
+ class="{styles.timeSelect} {!displayValue?.time ? styles.placeholder : ''}"
798
+ value={displayValue?.time?.minute ?? 0}
799
+ onchange={(e) => handleMinuteChange(e, false)}
800
+ {disabled}
801
+ >
802
+ {#each minutes as m}
803
+ <option value={m}>{String(m).padStart(2, '0')}</option>
804
+ {/each}
805
+ </select>
806
+ </div>
807
+ {/if}
808
+ {/if}
809
+ </div>
810
+ <i class="{styles.inputIcon} {inputIcon}"></i>
811
+ </div>
812
+
813
+ {#if isOpen}
814
+ <div class="{styles.dropdown} {align === 'right' ? styles.right : ''}">
815
+ {#if mode === 'period'}
816
+ <!-- Period mode: two calendars -->
817
+ <div class={styles.periodCalendars}>
818
+ <div class={styles.periodCalendarLeft}>
819
+ <div class={styles.calendar}>
820
+ <div class={styles.calendarNav}>
821
+ <button type="button" class={styles.navButton} onclick={() => handlePrevMonth(false)}>
822
+ <i class="icon-expand-left"></i>
823
+ </button>
824
+ <div class={styles.navTitle}>
825
+ <div class={styles.navSelectWrapper}>
826
+ <select class={styles.navSelect} value={viewDate.getFullYear()} onchange={(e) => handleYearChange(e, false)}>
827
+ {#each yearOptions as y}
828
+ <option value={y}>{y}년</option>
829
+ {/each}
830
+ </select>
831
+ </div>
832
+ <div class={styles.navSelectWrapper}>
833
+ <select class={styles.navSelect} value={viewDate.getMonth()} onchange={(e) => handleMonthChange(e, false)}>
834
+ {#each monthOptions as m}
835
+ <option value={m}>{m + 1}월</option>
836
+ {/each}
837
+ </select>
838
+ </div>
839
+ </div>
840
+ <button type="button" class={styles.navButton} onclick={() => handleNextMonth(false)}>
841
+ <i class="icon-expand-right"></i>
842
+ </button>
843
+ </div>
844
+ <div class={styles.calendarGrid}>
845
+ <div class={styles.calendarRow}>
846
+ {#each weekDays as day}
847
+ <div class="{styles.calendarCell} {styles.header}">{day}</div>
848
+ {/each}
849
+ </div>
850
+ {#each generateCalendarDays(viewDate) as week}
851
+ <div class={styles.calendarRow}>
852
+ {#each week as dayInfo}
853
+ <button
854
+ type="button"
855
+ class={getCellClass(dayInfo)}
856
+ onclick={() => !dayInfo.isDisabled && handleDateSelect(dayInfo.date)}
857
+ disabled={dayInfo.isDisabled}
858
+ >
859
+ {dayInfo.day}
860
+ </button>
861
+ {/each}
862
+ </div>
863
+ {/each}
864
+ </div>
865
+ </div>
866
+ </div>
867
+ <div class={styles.periodCalendarRight}>
868
+ <div class={styles.calendar}>
869
+ <div class={styles.calendarNav}>
870
+ <button type="button" class={styles.navButton} onclick={() => handlePrevMonth(true)}>
871
+ <i class="icon-expand-left"></i>
872
+ </button>
873
+ <div class={styles.navTitle}>
874
+ <div class={styles.navSelectWrapper}>
875
+ <select class={styles.navSelect} value={endViewDate.getFullYear()} onchange={(e) => handleYearChange(e, true)}>
876
+ {#each yearOptions as y}
877
+ <option value={y}>{y}년</option>
878
+ {/each}
879
+ </select>
880
+ </div>
881
+ <div class={styles.navSelectWrapper}>
882
+ <select class={styles.navSelect} value={endViewDate.getMonth()} onchange={(e) => handleMonthChange(e, true)}>
883
+ {#each monthOptions as m}
884
+ <option value={m}>{m + 1}월</option>
885
+ {/each}
886
+ </select>
887
+ </div>
888
+ </div>
889
+ <button type="button" class={styles.navButton} onclick={() => handleNextMonth(true)}>
890
+ <i class="icon-expand-right"></i>
891
+ </button>
892
+ </div>
893
+ <div class={styles.calendarGrid}>
894
+ <div class={styles.calendarRow}>
895
+ {#each weekDays as day}
896
+ <div class="{styles.calendarCell} {styles.header}">{day}</div>
897
+ {/each}
898
+ </div>
899
+ {#each generateCalendarDays(endViewDate) as week}
900
+ <div class={styles.calendarRow}>
901
+ {#each week as dayInfo}
902
+ <button
903
+ type="button"
904
+ class={getCellClass(dayInfo)}
905
+ onclick={() => !dayInfo.isDisabled && handleDateSelect(dayInfo.date)}
906
+ disabled={dayInfo.isDisabled}
907
+ >
908
+ {dayInfo.day}
909
+ </button>
910
+ {/each}
911
+ </div>
912
+ {/each}
913
+ </div>
914
+ </div>
915
+ </div>
916
+ </div>
917
+ {:else}
918
+ <!-- Instant mode: single calendar -->
919
+ <div class={styles.calendar}>
920
+ <div class={styles.calendarNav}>
921
+ <button type="button" class={styles.navButton} onclick={() => handlePrevMonth(false)}>
922
+ <i class="icon-expand-left"></i>
923
+ </button>
924
+ <div class={styles.navTitle}>
925
+ <div class={styles.navSelectWrapper}>
926
+ <select class={styles.navSelect} value={viewDate.getFullYear()} onchange={(e) => handleYearChange(e, false)}>
927
+ {#each yearOptions as y}
928
+ <option value={y}>{y}년</option>
929
+ {/each}
930
+ </select>
931
+ </div>
932
+ <div class={styles.navSelectWrapper}>
933
+ <select class={styles.navSelect} value={viewDate.getMonth()} onchange={(e) => handleMonthChange(e, false)}>
934
+ {#each monthOptions as m}
935
+ <option value={m}>{m + 1}월</option>
936
+ {/each}
937
+ </select>
938
+ </div>
939
+ </div>
940
+ <button type="button" class={styles.navButton} onclick={() => handleNextMonth(false)}>
941
+ <i class="icon-expand-right"></i>
942
+ </button>
943
+ </div>
944
+ <div class={styles.calendarGrid}>
945
+ <div class={styles.calendarRow}>
946
+ {#each weekDays as day}
947
+ <div class="{styles.calendarCell} {styles.header}">{day}</div>
948
+ {/each}
949
+ </div>
950
+ {#each generateCalendarDays(viewDate) as week}
951
+ <div class={styles.calendarRow}>
952
+ {#each week as dayInfo}
953
+ <button
954
+ type="button"
955
+ class={getCellClass(dayInfo)}
956
+ onclick={() => !dayInfo.isDisabled && handleDateSelect(dayInfo.date)}
957
+ disabled={dayInfo.isDisabled}
958
+ >
959
+ {dayInfo.day}
960
+ </button>
961
+ {/each}
962
+ </div>
963
+ {/each}
964
+ </div>
965
+ </div>
966
+ {/if}
967
+
968
+ {#if shouldShowActions}
969
+ <div class={styles.bottomActions}>
970
+ <span class={styles.periodText}>
971
+ {mode === 'period' && tempValue.date ? formatPeriodText() : ''}
972
+ </span>
973
+ <div class={styles.actionButtons}>
974
+ <button type="button" class="{styles.actionButton} {styles.reset}" onclick={handleReset}>
975
+ <i class="icon-refresh"></i>
976
+ 초기화
977
+ </button>
978
+ <button type="button" class="{styles.actionButton} {styles.apply}" onclick={handleApply}>
979
+ 적용
980
+ </button>
981
+ </div>
982
+ </div>
983
+ {/if}
984
+ </div>
985
+ {/if}
986
+ </div>