@intinyagroup/calendar 0.1.1-alpha.11

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,102 @@
1
+ <script lang="ts">
2
+ import { ChevronLeft, ChevronRight } from 'lucide-svelte';
3
+ import { Button } from '@intinyagroup/ui';
4
+ import { cn } from '@intinyagroup/ui/utils';
5
+
6
+ export type CalendarDate = { year: number; month: number; day: number };
7
+
8
+ let {
9
+ value = $bindable(null),
10
+ min = null,
11
+ max = null,
12
+ disabled = false,
13
+ class: className,
14
+ onSelect,
15
+ }: {
16
+ value?: CalendarDate | null;
17
+ min?: CalendarDate | null;
18
+ max?: CalendarDate | null;
19
+ disabled?: boolean;
20
+ class?: string;
21
+ onSelect?: (date: CalendarDate) => void;
22
+ } = $props();
23
+
24
+ const today = new Date();
25
+ let viewYear = $state(value?.year ?? today.getFullYear());
26
+ let viewMonth = $state(value?.month ?? today.getMonth());
27
+
28
+ const monthNames = ['January','February','March','April','May','June','July','August','September','October','November','December'];
29
+ const dayNames = ['Su','Mo','Tu','We','Th','Fr','Sa'];
30
+ const daysInMonth = $derived(new Date(viewYear, viewMonth + 1, 0).getDate());
31
+ const firstDayOfMonth = $derived(new Date(viewYear, viewMonth, 1).getDay());
32
+
33
+ const calendarDays = $derived.by(() => {
34
+ const days: { day: number; currentMonth: boolean; date: CalendarDate }[] = [];
35
+ const prevMonthDays = new Date(viewYear, viewMonth, 0).getDate();
36
+ for (let i = firstDayOfMonth - 1; i >= 0; i--) {
37
+ const day = prevMonthDays - i;
38
+ const month = viewMonth === 0 ? 11 : viewMonth - 1;
39
+ const year = viewMonth === 0 ? viewYear - 1 : viewYear;
40
+ days.push({ day, currentMonth: false, date: { year, month, day } });
41
+ }
42
+ for (let day = 1; day <= daysInMonth; day++) {
43
+ days.push({ day, currentMonth: true, date: { year: viewYear, month: viewMonth, day } });
44
+ }
45
+ const remaining = 42 - days.length;
46
+ for (let day = 1; day <= remaining; day++) {
47
+ const month = viewMonth === 11 ? 0 : viewMonth + 1;
48
+ const year = viewMonth === 11 ? viewYear + 1 : viewYear;
49
+ days.push({ day, currentMonth: false, date: { year, month, day } });
50
+ }
51
+ return days;
52
+ });
53
+
54
+ function isSameDate(a: CalendarDate | null, b: CalendarDate) {
55
+ return a?.year === b.year && a?.month === b.month && a?.day === b.day;
56
+ }
57
+ function isToday(date: CalendarDate) {
58
+ return date.year === today.getFullYear() && date.month === today.getMonth() && date.day === today.getDate();
59
+ }
60
+ function isDisabled(date: CalendarDate) {
61
+ if (min && (date.year < min.year || (date.year === min.year && date.month < min.month) || (date.year === min.year && date.month === min.month && date.day < min.day))) return true;
62
+ if (max && (date.year > max.year || (date.year === max.year && date.month > max.month) || (date.year === max.year && date.month === max.month && date.day > max.day))) return true;
63
+ return false;
64
+ }
65
+ function prevMonth() { if (viewMonth === 0) { viewMonth = 11; viewYear--; } else { viewMonth--; } }
66
+ function nextMonth() { if (viewMonth === 11) { viewMonth = 0; viewYear++; } else { viewMonth++; } }
67
+ function selectDate(date: CalendarDate) { if (disabled || isDisabled(date)) return; value = date; onSelect?.(date); }
68
+ function goToday() { viewYear = today.getFullYear(); viewMonth = today.getMonth(); selectDate({ year: today.getFullYear(), month: today.getMonth(), day: today.getDate() }); }
69
+ </script>
70
+
71
+ <div class={cn('inline-flex flex-col rounded-xl border border-[var(--ui-border)] bg-[var(--ui-card)] p-4 shadow-sm', className)}>
72
+ <div class="flex items-center justify-between mb-4">
73
+ <Button variant="ghost" size="sm" onclick={prevMonth} {disabled}><ChevronLeft class="size-4" /></Button>
74
+ <div class="flex items-center gap-2">
75
+ <span class="text-sm font-semibold text-[var(--ui-foreground)]">{monthNames[viewMonth]} {viewYear}</span>
76
+ <Button variant="ghost" size="sm" onclick={goToday} {disabled} class="text-xs h-6 px-2">Today</Button>
77
+ </div>
78
+ <Button variant="ghost" size="sm" onclick={nextMonth} {disabled}><ChevronRight class="size-4" /></Button>
79
+ </div>
80
+
81
+ <div class="grid grid-cols-7 gap-1 mb-2">
82
+ {#each dayNames as d}<div class="text-center text-[11px] font-medium text-[var(--ui-muted-foreground)] py-1">{d}</div>{/each}
83
+ </div>
84
+
85
+ <div class="grid grid-cols-7 gap-1">
86
+ {#each calendarDays as { day, currentMonth, date } (date.year + '-' + date.month + '-' + date.day)}
87
+ {@const isSelected = isSameDate(value, date)}
88
+ {@const isTodayDate = isToday(date)}
89
+ {@const isDisabledDate = isDisabled(date)}
90
+ <button
91
+ onclick={() => selectDate(date)}
92
+ disabled={disabled || isDisabledDate}
93
+ class="flex items-center justify-center size-9 rounded-lg text-sm transition-colors cursor-pointer
94
+ {isSelected ? 'bg-[var(--ui-primary)] text-[var(--ui-primary-foreground)] font-semibold' : ''}
95
+ {!isSelected && currentMonth ? 'text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]' : ''}
96
+ {!isSelected && !currentMonth ? 'text-[var(--ui-muted-foreground)]/40' : ''}
97
+ {isTodayDate && !isSelected ? 'ring-2 ring-[var(--ui-primary)]/30 font-medium' : ''}
98
+ {isDisabledDate || disabled ? 'opacity-40 cursor-not-allowed' : ''}"
99
+ >{day}</button>
100
+ {/each}
101
+ </div>
102
+ </div>
@@ -0,0 +1,16 @@
1
+ export type CalendarDate = {
2
+ year: number;
3
+ month: number;
4
+ day: number;
5
+ };
6
+ type $$ComponentProps = {
7
+ value?: CalendarDate | null;
8
+ min?: CalendarDate | null;
9
+ max?: CalendarDate | null;
10
+ disabled?: boolean;
11
+ class?: string;
12
+ onSelect?: (date: CalendarDate) => void;
13
+ };
14
+ declare const Calendar: import("svelte").Component<$$ComponentProps, {}, "value">;
15
+ type Calendar = ReturnType<typeof Calendar>;
16
+ export default Calendar;
@@ -0,0 +1,73 @@
1
+ <script lang="ts">
2
+ import * as Popover from '@intinyagroup/ui';
3
+ import { Button, Input } from '@intinyagroup/ui';
4
+ import { Calendar, type CalendarDate } from '@intinyagroup/calendar';
5
+ import { Calendar as CalendarIcon, X } from 'lucide-svelte';
6
+ import { cn } from '@intinyagroup/grid-core/utils';
7
+
8
+ let {
9
+ value = $bindable(null),
10
+ placeholder = 'Select date...',
11
+ disabled = false,
12
+ class: className,
13
+ onSelect,
14
+ }: {
15
+ value?: CalendarDate | null;
16
+ placeholder?: string;
17
+ disabled?: boolean;
18
+ class?: string;
19
+ onSelect?: (date: CalendarDate) => void;
20
+ } = $props();
21
+
22
+ let open = $state(false);
23
+
24
+ function formatDateDisplay(date: CalendarDate | null): string {
25
+ if (!date) return '';
26
+ const d = new Date(date.year, date.month, date.day);
27
+ return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
28
+ }
29
+
30
+ function handleSelect(date: CalendarDate) {
31
+ value = date;
32
+ onSelect?.(date);
33
+ open = false;
34
+ }
35
+
36
+ function clear() {
37
+ value = null;
38
+ onSelect?.(null as any);
39
+ }
40
+ </script>
41
+
42
+ <Popover.Root bind:open>
43
+ <Popover.Trigger>
44
+ {#snippet child({ props })}
45
+ <button
46
+ {...props}
47
+ class={cn(
48
+ 'flex h-10 w-full items-center justify-between rounded-lg border border-[var(--ui-input)] bg-[var(--ui-background)] px-3 py-2 text-sm text-[var(--ui-foreground)] transition-colors cursor-pointer',
49
+ 'hover:border-[var(--ui-primary)]/50 focus:border-[var(--ui-primary)] focus:ring-2 focus:ring-[var(--ui-ring)]/20',
50
+ disabled && 'opacity-50 cursor-not-allowed',
51
+ className
52
+ )}
53
+ {disabled}
54
+ >
55
+ <span class={cn(!value && 'text-[var(--ui-muted-foreground)]')}>
56
+ {value ? formatDateDisplay(value) : placeholder}
57
+ </span>
58
+ <CalendarIcon class="size-4 text-[var(--ui-muted-foreground)]" />
59
+ </button>
60
+ {/snippet}
61
+ </Popover.Trigger>
62
+
63
+ <Popover.Content align="start" class="w-auto p-0">
64
+ <Calendar {value} onSelect={handleSelect} />
65
+ {#if value}
66
+ <div class="flex justify-end p-2 border-t border-[var(--ui-border)]">
67
+ <Button variant="ghost" size="sm" onclick={clear}>
68
+ <X class="size-3.5 mr-1" /> Clear
69
+ </Button>
70
+ </div>
71
+ {/if}
72
+ </Popover.Content>
73
+ </Popover.Root>
@@ -0,0 +1,11 @@
1
+ import { type CalendarDate } from '@intinyagroup/calendar';
2
+ type $$ComponentProps = {
3
+ value?: CalendarDate | null;
4
+ placeholder?: string;
5
+ disabled?: boolean;
6
+ class?: string;
7
+ onSelect?: (date: CalendarDate) => void;
8
+ };
9
+ declare const DatePicker: import("svelte").Component<$$ComponentProps, {}, "value">;
10
+ type DatePicker = ReturnType<typeof DatePicker>;
11
+ export default DatePicker;
@@ -0,0 +1,310 @@
1
+ <script lang="ts">
2
+ import { Calendar as CalendarIcon, ChevronLeft, ChevronRight, X } from 'lucide-svelte';
3
+ import { Button } from '@intinyagroup/ui';
4
+ import { cn } from '@intinyagroup/ui/utils';
5
+ import {
6
+ addMonths,
7
+ subMonths,
8
+ addDays,
9
+ subDays,
10
+ startOfMonth,
11
+ endOfMonth,
12
+ startOfWeek,
13
+ endOfWeek,
14
+ eachDayOfInterval,
15
+ isSameDay,
16
+ isSameMonth,
17
+ isWithinInterval,
18
+ isBefore,
19
+ format
20
+ } from 'date-fns';
21
+ import { id as localeId, enUS as localeEn } from 'date-fns/locale';
22
+
23
+ export type CalendarDate = { year: number; month: number; day: number };
24
+ export type DateRange = {
25
+ start: CalendarDate | null;
26
+ end: CalendarDate | null;
27
+ };
28
+
29
+ export type DateRangePreset = {
30
+ label: string;
31
+ range: DateRange;
32
+ };
33
+
34
+ let {
35
+ value = $bindable({ start: null, end: null }),
36
+ min = null,
37
+ max = null,
38
+ disabled = false,
39
+ placeholder = 'Select date range...',
40
+ locale = 'en-US',
41
+ firstDayOfWeek = 0, // 0 = Sunday, 1 = Monday
42
+ presets,
43
+ class: className,
44
+ onSelect,
45
+ }: {
46
+ value?: DateRange;
47
+ min?: CalendarDate | null;
48
+ max?: CalendarDate | null;
49
+ disabled?: boolean;
50
+ placeholder?: string;
51
+ locale?: string;
52
+ firstDayOfWeek?: number;
53
+ presets?: DateRangePreset[];
54
+ class?: string;
55
+ onSelect?: (range: DateRange) => void;
56
+ } = $props();
57
+
58
+ let isOpen = $state(false);
59
+ const today = new Date();
60
+
61
+ let viewDate = $state(
62
+ value?.start ? new Date(value.start.year, value.start.month, value.start.day) : today
63
+ );
64
+ let hoverDate = $state<Date | null>(null);
65
+
66
+ const activeDateFnsLocale = $derived(locale.startsWith('id') ? localeId : localeEn);
67
+ const weekStartsOn = $derived(firstDayOfWeek as 0 | 1 | 2 | 3 | 4 | 5 | 6);
68
+
69
+ function toDate(d: CalendarDate | null): Date | null {
70
+ if (!d) return null;
71
+ return new Date(d.year, d.month, d.day);
72
+ }
73
+
74
+ function toCalendarDate(date: Date): CalendarDate {
75
+ return {
76
+ year: date.getFullYear(),
77
+ month: date.getMonth(),
78
+ day: date.getDate()
79
+ };
80
+ }
81
+
82
+ // Generate day names for header using date-fns
83
+ const dayNames = $derived.by(() => {
84
+ const start = startOfWeek(viewDate, { weekStartsOn });
85
+ return Array.from({ length: 7 }, (_, i) => {
86
+ return format(addDays(start, i), 'EEE', { locale: activeDateFnsLocale });
87
+ });
88
+ });
89
+
90
+ // Generate full calendar grid (days from start of week of month-start to end of week of month-end)
91
+ const calendarDays = $derived.by(() => {
92
+ const monthStart = startOfMonth(viewDate);
93
+ const monthEnd = endOfMonth(viewDate);
94
+ const gridStart = startOfWeek(monthStart, { weekStartsOn });
95
+ const gridEnd = endOfWeek(monthEnd, { weekStartsOn });
96
+
97
+ const days = eachDayOfInterval({ start: gridStart, end: gridEnd });
98
+ return days.map((d) => ({
99
+ date: d,
100
+ calendarDate: toCalendarDate(d),
101
+ currentMonth: isSameMonth(d, viewDate)
102
+ }));
103
+ });
104
+
105
+ const startDateObj = $derived(toDate(value.start));
106
+ const endDateObj = $derived(toDate(value.end));
107
+
108
+ function handleDateClick(date: Date) {
109
+ if (disabled) return;
110
+
111
+ if (!startDateObj || (startDateObj && endDateObj)) {
112
+ value = { start: toCalendarDate(date), end: null };
113
+ } else if (startDateObj && !endDateObj) {
114
+ if (isBefore(date, startDateObj)) {
115
+ value = { start: toCalendarDate(date), end: toCalendarDate(startDateObj) };
116
+ } else {
117
+ value = { start: toCalendarDate(startDateObj), end: toCalendarDate(date) };
118
+ }
119
+ onSelect?.(value);
120
+ isOpen = false;
121
+ }
122
+ }
123
+
124
+ function prevMonth() {
125
+ viewDate = subMonths(viewDate, 1);
126
+ }
127
+
128
+ function nextMonth() {
129
+ viewDate = addMonths(viewDate, 1);
130
+ }
131
+
132
+ function formatDisplayDate(date: CalendarDate | null): string {
133
+ const d = toDate(date);
134
+ if (!d) return '';
135
+ return format(d, 'dd/MM/yyyy', { locale: activeDateFnsLocale });
136
+ }
137
+
138
+ const label = $derived.by(() => {
139
+ if (value.start && value.end) {
140
+ return `${formatDisplayDate(value.start)} - ${formatDisplayDate(value.end)}`;
141
+ }
142
+ if (value.start) {
143
+ return `${formatDisplayDate(value.start)} - ...`;
144
+ }
145
+ return placeholder;
146
+ });
147
+
148
+ function clearRange(e: MouseEvent) {
149
+ e.stopPropagation();
150
+ value = { start: null, end: null };
151
+ onSelect?.(value);
152
+ }
153
+
154
+ // Rich presets using date-fns intervals
155
+ const activePresets = $derived.by<DateRangePreset[]>(() => {
156
+ if (presets) return presets;
157
+
158
+ const y = today.getFullYear();
159
+
160
+ return [
161
+ {
162
+ label: 'Today',
163
+ range: { start: toCalendarDate(today), end: toCalendarDate(today) }
164
+ },
165
+ {
166
+ label: 'Next 7D',
167
+ range: { start: toCalendarDate(today), end: toCalendarDate(addDays(today, 7)) }
168
+ },
169
+ {
170
+ label: 'This Month',
171
+ range: { start: toCalendarDate(startOfMonth(today)), end: toCalendarDate(endOfMonth(today)) }
172
+ },
173
+ {
174
+ label: 'Last Month',
175
+ range: {
176
+ start: toCalendarDate(startOfMonth(subMonths(today, 1))),
177
+ end: toCalendarDate(endOfMonth(subMonths(today, 1)))
178
+ }
179
+ },
180
+ {
181
+ label: 'YTD',
182
+ range: { start: { year: y, month: 0, day: 1 }, end: toCalendarDate(today) }
183
+ }
184
+ ];
185
+ });
186
+ </script>
187
+
188
+ <div class={cn('relative inline-block w-full max-w-sm', className)}>
189
+ <button
190
+ type="button"
191
+ {disabled}
192
+ onclick={() => (isOpen = !isOpen)}
193
+ class={cn(
194
+ 'flex h-10 w-full items-center justify-between rounded-lg border border-[var(--ui-input)] bg-[var(--ui-background)] px-3 text-sm transition-colors text-left',
195
+ 'hover:border-[var(--ui-border)] focus:outline-none focus:ring-2 focus:ring-[var(--ui-ring)]/20 focus:border-[var(--ui-primary)]',
196
+ disabled && 'opacity-50 cursor-not-allowed',
197
+ value.start ? 'text-[var(--ui-foreground)]' : 'text-[var(--ui-muted-foreground)]'
198
+ )}
199
+ >
200
+ <div class="flex items-center gap-2 truncate">
201
+ <CalendarIcon class="size-4 shrink-0 text-[var(--ui-muted-foreground)]" />
202
+ <span class="truncate">{label}</span>
203
+ </div>
204
+
205
+ {#if value.start && !disabled}
206
+ <button
207
+ type="button"
208
+ onclick={clearRange}
209
+ class="p-1 rounded-full hover:bg-[var(--ui-secondary)] text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)]"
210
+ aria-label="Clear date range"
211
+ >
212
+ <X class="size-3.5" />
213
+ </button>
214
+ {/if}
215
+ </button>
216
+
217
+ {#if isOpen}
218
+ <!-- Backdrop for click outside -->
219
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
220
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
221
+ <div class="fixed inset-0 z-40" onclick={() => (isOpen = false)}></div>
222
+
223
+ <div
224
+ class="absolute left-0 top-full z-50 mt-1.5 w-80 rounded-xl border border-[var(--ui-border)] bg-[var(--ui-card)] p-3.5 shadow-xl animate-in fade-in-50 zoom-in-95 select-none"
225
+ >
226
+ <!-- Header controls -->
227
+ <div class="flex items-center justify-between mb-3">
228
+ <Button variant="ghost" size="sm" class="size-7 p-0" onclick={prevMonth}>
229
+ <ChevronLeft class="size-4" />
230
+ </Button>
231
+ <span class="text-xs font-semibold text-[var(--ui-foreground)] capitalize">
232
+ {format(viewDate, 'MMMM yyyy', { locale: activeDateFnsLocale })}
233
+ </span>
234
+ <Button variant="ghost" size="sm" class="size-7 p-0" onclick={nextMonth}>
235
+ <ChevronRight class="size-4" />
236
+ </Button>
237
+ </div>
238
+
239
+ <!-- Day names -->
240
+ <div class="grid grid-cols-7 mb-1 text-center">
241
+ {#each dayNames as day}
242
+ <span class="text-[11px] font-semibold text-[var(--ui-muted-foreground)] py-1 capitalize">
243
+ {day}
244
+ </span>
245
+ {/each}
246
+ </div>
247
+
248
+ <!-- Calendar grid -->
249
+ <div class="grid grid-cols-7 gap-y-1">
250
+ {#each calendarDays as { date, calendarDate, currentMonth }}
251
+ {@const isStart = startDateObj ? isSameDay(startDateObj, date) : false}
252
+ {@const isEnd = endDateObj ? isSameDay(endDateObj, date) : false}
253
+ {@const activeRangeEnd = endDateObj || hoverDate}
254
+ {@const inRange =
255
+ startDateObj && activeRangeEnd
256
+ ? isWithinInterval(date, {
257
+ start: isBefore(startDateObj, activeRangeEnd) ? startDateObj : activeRangeEnd,
258
+ end: isBefore(startDateObj, activeRangeEnd) ? activeRangeEnd : startDateObj
259
+ })
260
+ : false}
261
+ {@const isEdge = isStart || isEnd}
262
+
263
+ <div
264
+ class={cn(
265
+ 'relative py-0.5 flex items-center justify-center',
266
+ inRange && !isEdge && 'bg-[var(--ui-primary)]/10',
267
+ isStart && activeRangeEnd && !isSameDay(startDateObj, activeRangeEnd) && 'rounded-l-md bg-[var(--ui-primary)]/10',
268
+ isEnd && startDateObj && !isSameDay(startDateObj, endDateObj) && 'rounded-r-md bg-[var(--ui-primary)]/10'
269
+ )}
270
+ onmouseenter={() => {
271
+ if (startDateObj && !endDateObj) hoverDate = date;
272
+ }}
273
+ >
274
+ <button
275
+ type="button"
276
+ disabled={!currentMonth}
277
+ onclick={() => handleDateClick(date)}
278
+ class={cn(
279
+ 'size-8 rounded-md text-xs font-medium transition-colors flex items-center justify-center cursor-pointer',
280
+ !currentMonth && 'opacity-20 cursor-default',
281
+ currentMonth && !isEdge && !inRange && 'hover:bg-[var(--ui-secondary)] text-[var(--ui-foreground)]',
282
+ inRange && !isEdge && 'text-[var(--ui-primary)] font-semibold',
283
+ isEdge && 'bg-[var(--ui-primary)] text-[var(--ui-primary-foreground)] font-bold shadow-xs'
284
+ )}
285
+ >
286
+ {date.getDate()}
287
+ </button>
288
+ </div>
289
+ {/each}
290
+ </div>
291
+
292
+ <!-- Presets quick buttons bar -->
293
+ <div class="flex flex-wrap items-center gap-1.5 border-t border-[var(--ui-border)] mt-3 pt-2.5">
294
+ {#each activePresets as preset}
295
+ <button
296
+ type="button"
297
+ onclick={() => {
298
+ value = preset.range;
299
+ onSelect?.(value);
300
+ isOpen = false;
301
+ }}
302
+ class="rounded-md border border-[var(--ui-border)] bg-[var(--ui-secondary)]/30 px-2 py-1 text-[10px] font-medium text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)] transition-colors cursor-pointer"
303
+ >
304
+ {preset.label}
305
+ </button>
306
+ {/each}
307
+ </div>
308
+ </div>
309
+ {/if}
310
+ </div>
@@ -0,0 +1,28 @@
1
+ export type CalendarDate = {
2
+ year: number;
3
+ month: number;
4
+ day: number;
5
+ };
6
+ export type DateRange = {
7
+ start: CalendarDate | null;
8
+ end: CalendarDate | null;
9
+ };
10
+ export type DateRangePreset = {
11
+ label: string;
12
+ range: DateRange;
13
+ };
14
+ type $$ComponentProps = {
15
+ value?: DateRange;
16
+ min?: CalendarDate | null;
17
+ max?: CalendarDate | null;
18
+ disabled?: boolean;
19
+ placeholder?: string;
20
+ locale?: string;
21
+ firstDayOfWeek?: number;
22
+ presets?: DateRangePreset[];
23
+ class?: string;
24
+ onSelect?: (range: DateRange) => void;
25
+ };
26
+ declare const DateRangePicker: import("svelte").Component<$$ComponentProps, {}, "value">;
27
+ type DateRangePicker = ReturnType<typeof DateRangePicker>;
28
+ export default DateRangePicker;
@@ -0,0 +1,568 @@
1
+ <script lang="ts">
2
+ import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, Clock, Plus, X } from 'lucide-svelte';
3
+ import { Button } from '@intinyagroup/ui';
4
+ import { cn } from '@intinyagroup/ui/utils';
5
+ import {
6
+ addMonths,
7
+ subMonths,
8
+ addWeeks,
9
+ subWeeks,
10
+ addDays,
11
+ subDays,
12
+ startOfMonth,
13
+ endOfMonth,
14
+ startOfWeek,
15
+ endOfWeek,
16
+ eachDayOfInterval,
17
+ isSameDay,
18
+ isSameMonth,
19
+ format
20
+ } from 'date-fns';
21
+ import { id as localeId, enUS as localeEn } from 'date-fns/locale';
22
+
23
+ export type CalendarView = 'month' | 'week' | 'day';
24
+
25
+ export type CalendarEvent = {
26
+ id: string;
27
+ title: string;
28
+ start: Date;
29
+ end: Date;
30
+ color?: string;
31
+ description?: string;
32
+ allDay?: boolean;
33
+ };
34
+
35
+ let {
36
+ events = $bindable([]),
37
+ view = $bindable('month' as CalendarView),
38
+ currentDate = $bindable(new Date()),
39
+ locale = 'en-US',
40
+ timeZone,
41
+ firstDayOfWeek = 0, // 0 = Sunday, 1 = Monday
42
+ enableEventModal = true,
43
+ class: className,
44
+ onEventClick,
45
+ onDateClick,
46
+ onAddEvent,
47
+ }: {
48
+ events?: CalendarEvent[];
49
+ view?: CalendarView;
50
+ currentDate?: Date;
51
+ locale?: string;
52
+ timeZone?: string;
53
+ firstDayOfWeek?: number;
54
+ enableEventModal?: boolean;
55
+ class?: string;
56
+ onEventClick?: (event: CalendarEvent) => void;
57
+ onDateClick?: (date: Date) => void;
58
+ onAddEvent?: (newEvent: CalendarEvent) => void;
59
+ } = $props();
60
+
61
+ const activeDateFnsLocale = $derived(locale.startsWith('id') ? localeId : localeEn);
62
+ const weekStartsOn = $derived(firstDayOfWeek as 0 | 1 | 2 | 3 | 4 | 5 | 6);
63
+
64
+ const activeTimeZone = $derived(
65
+ timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
66
+ );
67
+
68
+ const hours = Array.from({ length: 24 }, (_, i) => i);
69
+
70
+ // Day names localized and rotated according to firstDayOfWeek via date-fns
71
+ const dayNames = $derived.by(() => {
72
+ const start = startOfWeek(currentDate, { weekStartsOn });
73
+ return Array.from({ length: 7 }, (_, i) => {
74
+ return format(addDays(start, i), 'EEE', { locale: activeDateFnsLocale });
75
+ });
76
+ });
77
+
78
+ function isToday(d: Date): boolean {
79
+ return isSameDay(d, new Date());
80
+ }
81
+
82
+ function goPrev() {
83
+ if (view === 'month') currentDate = subMonths(currentDate, 1);
84
+ else if (view === 'week') currentDate = subWeeks(currentDate, 1);
85
+ else currentDate = subDays(currentDate, 1);
86
+ }
87
+
88
+ function goNext() {
89
+ if (view === 'month') currentDate = addMonths(currentDate, 1);
90
+ else if (view === 'week') currentDate = addWeeks(currentDate, 1);
91
+ else currentDate = addDays(currentDate, 1);
92
+ }
93
+
94
+ function goToday() {
95
+ currentDate = new Date();
96
+ }
97
+
98
+ // Month view calendar matrix using date-fns intervals
99
+ const monthDays = $derived.by(() => {
100
+ const monthStart = startOfMonth(currentDate);
101
+ const monthEnd = endOfMonth(currentDate);
102
+ const gridStart = startOfWeek(monthStart, { weekStartsOn });
103
+ const gridEnd = endOfWeek(monthEnd, { weekStartsOn });
104
+
105
+ const days = eachDayOfInterval({ start: gridStart, end: gridEnd });
106
+ return days.map((d) => ({
107
+ date: d,
108
+ currentMonth: isSameMonth(d, currentDate)
109
+ }));
110
+ });
111
+
112
+ // Week view 7 days using date-fns
113
+ const weekDays = $derived.by(() => {
114
+ const start = startOfWeek(currentDate, { weekStartsOn });
115
+ return Array.from({ length: 7 }, (_, i) => addDays(start, i));
116
+ });
117
+
118
+ function getEventsForDay(d: Date): CalendarEvent[] {
119
+ return events.filter((ev) => isSameDay(new Date(ev.start), d));
120
+ }
121
+
122
+ function getTimeInZone(date: Date): { hours: number; minutes: number } {
123
+ const parts = new Intl.DateTimeFormat('en-US', {
124
+ timeZone: activeTimeZone,
125
+ hour: 'numeric',
126
+ minute: 'numeric',
127
+ hour12: false
128
+ }).formatToParts(date);
129
+
130
+ let hours = 0;
131
+ let minutes = 0;
132
+ for (const p of parts) {
133
+ if (p.type === 'hour') hours = Number(p.value) % 24;
134
+ if (p.type === 'minute') minutes = Number(p.value);
135
+ }
136
+ return { hours, minutes };
137
+ }
138
+
139
+ function formatTime(date: Date): string {
140
+ return new Intl.DateTimeFormat(locale, {
141
+ timeZone: activeTimeZone,
142
+ hour: '2-digit',
143
+ minute: '2-digit',
144
+ hour12: false
145
+ }).format(date);
146
+ }
147
+
148
+ const titleHeader = $derived.by(() => {
149
+ if (view === 'month') {
150
+ return format(currentDate, 'MMMM yyyy', { locale: activeDateFnsLocale });
151
+ }
152
+ if (view === 'week') {
153
+ const start = weekDays[0];
154
+ const end = weekDays[6];
155
+ const sStr = format(start, 'dd MMM', { locale: activeDateFnsLocale });
156
+ const eStr = format(end, 'dd MMM yyyy', { locale: activeDateFnsLocale });
157
+ return `${sStr} - ${eStr}`;
158
+ }
159
+ return format(currentDate, 'dd MMMM yyyy', { locale: activeDateFnsLocale });
160
+ });
161
+
162
+ // Event modal state
163
+ let modalOpen = $state(false);
164
+ let newEventTitle = $state('');
165
+ let newEventDate = $state(new Date());
166
+ let newEventStartTime = $state('09:00');
167
+ let newEventEndTime = $state('10:00');
168
+ let newEventColor = $state('#2563eb');
169
+ let newEventDescription = $state('');
170
+
171
+ const presetColors = ['#2563eb', '#7c3aed', '#059669', '#d97706', '#dc2626', '#0891b2', '#4b5563'];
172
+
173
+ function openCreateModal(date: Date) {
174
+ if (!enableEventModal) return;
175
+ newEventDate = new Date(date);
176
+ newEventTitle = '';
177
+ newEventStartTime = '09:00';
178
+ newEventEndTime = '10:00';
179
+ newEventColor = '#2563eb';
180
+ newEventDescription = '';
181
+ modalOpen = true;
182
+ }
183
+
184
+ function handleSaveEvent() {
185
+ if (!newEventTitle.trim()) return;
186
+
187
+ const [startH, startM] = newEventStartTime.split(':').map(Number);
188
+ const [endH, endM] = newEventEndTime.split(':').map(Number);
189
+
190
+ const startDate = new Date(newEventDate);
191
+ startDate.setHours(startH || 0, startM || 0, 0, 0);
192
+
193
+ const endDate = new Date(newEventDate);
194
+ endDate.setHours(endH || 0, endM || 0, 0, 0);
195
+
196
+ const newEv: CalendarEvent = {
197
+ id: `ev-${Date.now()}`,
198
+ title: newEventTitle.trim(),
199
+ start: startDate,
200
+ end: endDate,
201
+ color: newEventColor,
202
+ description: newEventDescription.trim() || undefined
203
+ };
204
+
205
+ events = [...events, newEv];
206
+ onAddEvent?.(newEv);
207
+ modalOpen = false;
208
+ }
209
+ </script>
210
+
211
+ <div class={cn('relative flex flex-col rounded-xl border border-[var(--ui-border)] bg-[var(--ui-card)] shadow-xs overflow-hidden', className)}>
212
+ <!-- Toolbar Header -->
213
+ <div class="flex flex-wrap items-center justify-between gap-3 border-b border-[var(--ui-border)] p-4 sm:px-6">
214
+ <div class="flex items-center gap-2">
215
+ <h2 class="text-base font-bold text-[var(--ui-foreground)] capitalize">{titleHeader}</h2>
216
+ <div class="flex items-center gap-1 ml-2">
217
+ <Button variant="outline" size="sm" class="size-8 p-0" onclick={goPrev}>
218
+ <ChevronLeft class="size-4" />
219
+ </Button>
220
+ <Button variant="outline" size="sm" class="px-2.5 h-8 text-xs font-semibold" onclick={goToday}>
221
+ Today
222
+ </Button>
223
+ <Button variant="outline" size="sm" class="size-8 p-0" onclick={goNext}>
224
+ <ChevronRight class="size-4" />
225
+ </Button>
226
+ </div>
227
+ <span class="inline-flex items-center rounded-md bg-[var(--ui-secondary)]/50 px-2 py-0.5 text-[10px] font-medium text-[var(--ui-muted-foreground)]">
228
+ {activeTimeZone}
229
+ </span>
230
+ </div>
231
+
232
+ <div class="flex items-center gap-2">
233
+ <!-- View selector tabs -->
234
+ <div class="inline-flex rounded-lg border border-[var(--ui-border)] bg-[var(--ui-secondary)]/30 p-1">
235
+ <button
236
+ type="button"
237
+ onclick={() => (view = 'month')}
238
+ class={cn(
239
+ 'rounded-md px-3 py-1 text-xs font-semibold transition-colors cursor-pointer',
240
+ view === 'month' ? 'bg-[var(--ui-card)] text-[var(--ui-foreground)] shadow-xs' : 'text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)]'
241
+ )}
242
+ >
243
+ Month
244
+ </button>
245
+ <button
246
+ type="button"
247
+ onclick={() => (view = 'week')}
248
+ class={cn(
249
+ 'rounded-md px-3 py-1 text-xs font-semibold transition-colors cursor-pointer',
250
+ view === 'week' ? 'bg-[var(--ui-card)] text-[var(--ui-foreground)] shadow-xs' : 'text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)]'
251
+ )}
252
+ >
253
+ Week
254
+ </button>
255
+ <button
256
+ type="button"
257
+ onclick={() => (view = 'day')}
258
+ class={cn(
259
+ 'rounded-md px-3 py-1 text-xs font-semibold transition-colors cursor-pointer',
260
+ view === 'day' ? 'bg-[var(--ui-card)] text-[var(--ui-foreground)] shadow-xs' : 'text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)]'
261
+ )}
262
+ >
263
+ Day
264
+ </button>
265
+ </div>
266
+
267
+ {#if enableEventModal}
268
+ <Button size="sm" class="gap-1.5 h-8 text-xs" onclick={() => openCreateModal(currentDate)}>
269
+ <Plus class="size-3.5" /> Add Event
270
+ </Button>
271
+ {/if}
272
+ </div>
273
+ </div>
274
+
275
+ <!-- Calendar Content -->
276
+ {#if view === 'month'}
277
+ <!-- MONTH VIEW -->
278
+ <div class="grid grid-cols-7 border-b border-[var(--ui-border)] bg-[var(--ui-secondary)]/20 text-center text-xs font-semibold text-[var(--ui-muted-foreground)] py-2">
279
+ {#each dayNames as day}
280
+ <div class="capitalize">{day}</div>
281
+ {/each}
282
+ </div>
283
+
284
+ <div class="grid grid-cols-7 divide-x divide-y divide-[var(--ui-border)]">
285
+ {#each monthDays as { date, currentMonth }}
286
+ {@const dayEvents = getEventsForDay(date)}
287
+ {@const todayActive = isToday(date)}
288
+
289
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
290
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
291
+ <div
292
+ onclick={() => {
293
+ onDateClick?.(date);
294
+ openCreateModal(date);
295
+ }}
296
+ class={cn(
297
+ 'min-h-[100px] p-2 flex flex-col gap-1 transition-colors hover:bg-[var(--ui-secondary)]/25 cursor-pointer',
298
+ !currentMonth && 'bg-[var(--ui-muted)]/15 opacity-50'
299
+ )}
300
+ >
301
+ <div class="flex items-center justify-between mb-1">
302
+ <span
303
+ class={cn(
304
+ 'text-xs font-semibold size-6 flex items-center justify-center rounded-full',
305
+ todayActive ? 'bg-[var(--ui-primary)] text-[var(--ui-primary-foreground)]' : 'text-[var(--ui-foreground)]'
306
+ )}
307
+ >
308
+ {date.getDate()}
309
+ </span>
310
+ {#if dayEvents.length > 0}
311
+ <span class="text-[10px] text-[var(--ui-muted-foreground)] font-medium">
312
+ {dayEvents.length} ev
313
+ </span>
314
+ {/if}
315
+ </div>
316
+
317
+ <!-- Event pills -->
318
+ <div class="flex flex-col gap-1 overflow-hidden">
319
+ {#each dayEvents.slice(0, 3) as ev (ev.id)}
320
+ <button
321
+ type="button"
322
+ onclick={(e) => {
323
+ e.stopPropagation();
324
+ onEventClick?.(ev);
325
+ }}
326
+ class={cn(
327
+ 'truncate rounded px-1.5 py-0.5 text-left text-[11px] font-medium transition-opacity hover:opacity-85 text-white shadow-2xs',
328
+ ev.color ? '' : 'bg-[var(--ui-primary)]'
329
+ )}
330
+ style={ev.color ? `background-color: ${ev.color};` : undefined}
331
+ >
332
+ {formatTime(ev.start)} {ev.title}
333
+ </button>
334
+ {/each}
335
+
336
+ {#if dayEvents.length > 3}
337
+ <span class="text-[10px] font-semibold text-[var(--ui-muted-foreground)] pl-1">
338
+ +{dayEvents.length - 3} more
339
+ </span>
340
+ {/if}
341
+ </div>
342
+ </div>
343
+ {/each}
344
+ </div>
345
+ {:else if view === 'week'}
346
+ <!-- WEEK VIEW -->
347
+ <div class="grid grid-cols-8 border-b border-[var(--ui-border)] bg-[var(--ui-secondary)]/20 text-center text-xs font-semibold text-[var(--ui-muted-foreground)] py-2">
348
+ <div class="col-span-1">Time</div>
349
+ {#each weekDays as d, idx}
350
+ <div class="col-span-1 flex flex-col items-center">
351
+ <span class="capitalize">{dayNames[idx]}</span>
352
+ <span class={cn('size-6 flex items-center justify-center rounded-full text-xs mt-0.5', isToday(d) ? 'bg-[var(--ui-primary)] text-[var(--ui-primary-foreground)]' : '')}>
353
+ {d.getDate()}
354
+ </span>
355
+ </div>
356
+ {/each}
357
+ </div>
358
+
359
+ <div class="grid grid-cols-8 divide-x divide-[var(--ui-border)] max-h-[600px] overflow-y-auto">
360
+ <!-- Hours Column -->
361
+ <div class="col-span-1 divide-y divide-[var(--ui-border)]/50 text-right pr-2 text-[11px] font-medium text-[var(--ui-muted-foreground)]">
362
+ {#each hours as hour}
363
+ <div class="h-14 pt-1">{String(hour).padStart(2, '0')}:00</div>
364
+ {/each}
365
+ </div>
366
+
367
+ <!-- 7 Days Grid Columns -->
368
+ {#each weekDays as d}
369
+ {@const dayEvents = getEventsForDay(d)}
370
+ {@const isCurrentDay = isToday(d)}
371
+ {@const nowTime = getTimeInZone(new Date())}
372
+ {@const nowMinutes = nowTime.hours * 60 + nowTime.minutes}
373
+ {@const currentTimeTop = (nowMinutes / 60) * 56}
374
+
375
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
376
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
377
+ <div
378
+ onclick={() => openCreateModal(d)}
379
+ class="col-span-1 divide-y divide-[var(--ui-border)]/50 relative hover:bg-[var(--ui-secondary)]/10 cursor-pointer"
380
+ >
381
+ {#each hours as _}
382
+ <div class="h-14"></div>
383
+ {/each}
384
+
385
+ <!-- Google Calendar style Current Time Red Line -->
386
+ {#if isCurrentDay}
387
+ <div
388
+ class="absolute inset-x-0 z-30 pointer-events-none flex items-center"
389
+ style="top: {currentTimeTop}px;"
390
+ >
391
+ <div class="size-2 rounded-full bg-red-500 -ml-1"></div>
392
+ <div class="h-0.5 w-full bg-red-500"></div>
393
+ </div>
394
+ {/if}
395
+
396
+ <!-- Render Event Boxes -->
397
+ {#each dayEvents as ev (ev.id)}
398
+ {@const evTime = getTimeInZone(new Date(ev.start))}
399
+ {@const topPos = (evTime.hours + evTime.minutes / 60) * 56}
400
+ <button
401
+ type="button"
402
+ onclick={(e) => {
403
+ e.stopPropagation();
404
+ onEventClick?.(ev);
405
+ }}
406
+ class="absolute inset-x-1 rounded p-1 text-left text-[11px] text-white shadow-xs transition-opacity hover:opacity-90 overflow-hidden z-10"
407
+ style="top: {topPos}px; height: 50px; background-color: {ev.color || 'var(--ui-primary)'};"
408
+ >
409
+ <div class="font-bold truncate">{ev.title}</div>
410
+ <div class="text-[9px] opacity-80">{formatTime(ev.start)} - {formatTime(ev.end)}</div>
411
+ </button>
412
+ {/each}
413
+ </div>
414
+ {/each}
415
+ </div>
416
+ {:else}
417
+ <!-- DAY VIEW -->
418
+ <div class="p-3 border-b border-[var(--ui-border)] bg-[var(--ui-secondary)]/20 flex items-center justify-between">
419
+ <div class="flex items-center gap-2">
420
+ <Clock class="size-4 text-[var(--ui-muted-foreground)]" />
421
+ <span class="text-sm font-semibold capitalize">{titleHeader}</span>
422
+ </div>
423
+ <span class="text-xs text-[var(--ui-muted-foreground)] font-medium">
424
+ {getEventsForDay(currentDate).length} event(s)
425
+ </span>
426
+ </div>
427
+
428
+ <div class="grid grid-cols-12 divide-x divide-[var(--ui-border)] max-h-[600px] overflow-y-auto">
429
+ <div class="col-span-2 divide-y divide-[var(--ui-border)]/50 text-right pr-3 text-xs font-medium text-[var(--ui-muted-foreground)]">
430
+ {#each hours as hour}
431
+ <div class="h-16 pt-2">{String(hour).padStart(2, '0')}:00</div>
432
+ {/each}
433
+ </div>
434
+
435
+ {@const isCurrentDay = isToday(currentDate)}
436
+ {@const nowTime = getTimeInZone(new Date())}
437
+ {@const nowMinutes = nowTime.hours * 60 + nowTime.minutes}
438
+ {@const currentTimeTop = (nowMinutes / 60) * 64}
439
+
440
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
441
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
442
+ <div
443
+ onclick={() => openCreateModal(currentDate)}
444
+ class="col-span-10 divide-y divide-[var(--ui-border)]/50 relative p-1 hover:bg-[var(--ui-secondary)]/10 cursor-pointer"
445
+ >
446
+ {#each hours as _}
447
+ <div class="h-16"></div>
448
+ {/each}
449
+
450
+ <!-- Google Calendar style Current Time Red Line -->
451
+ {#if isCurrentDay}
452
+ <div
453
+ class="absolute inset-x-0 z-30 pointer-events-none flex items-center"
454
+ style="top: {currentTimeTop}px;"
455
+ >
456
+ <div class="size-2.5 rounded-full bg-red-500 -ml-1"></div>
457
+ <div class="h-0.5 w-full bg-red-500"></div>
458
+ </div>
459
+ {/if}
460
+
461
+ {#each getEventsForDay(currentDate) as ev (ev.id)}
462
+ {@const evTime = getTimeInZone(new Date(ev.start))}
463
+ {@const topPos = (evTime.hours + evTime.minutes / 60) * 64}
464
+ <button
465
+ type="button"
466
+ onclick={(e) => {
467
+ e.stopPropagation();
468
+ onEventClick?.(ev);
469
+ }}
470
+ class="absolute inset-x-4 rounded-lg p-2 text-left text-white shadow-sm transition-opacity hover:opacity-90 flex flex-col justify-center z-10"
471
+ style="top: {topPos}px; height: 56px; background-color: {ev.color || 'var(--ui-primary)'};"
472
+ >
473
+ <div class="font-bold text-xs">{ev.title}</div>
474
+ <div class="text-[10px] opacity-80">{formatTime(ev.start)} - {formatTime(ev.end)} {#if ev.description}• {ev.description}{/if}</div>
475
+ </button>
476
+ {/each}
477
+ </div>
478
+ </div>
479
+ {/if}
480
+
481
+ <!-- Quick Event Creation Modal -->
482
+ {#if modalOpen}
483
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
484
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
485
+ <div class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-xs p-4">
486
+ <div
487
+ onclick={(e) => e.stopPropagation()}
488
+ class="w-full max-w-md rounded-xl border border-[var(--ui-border)] bg-[var(--ui-card)] p-5 shadow-2xl animate-in fade-in-50 zoom-in-95"
489
+ >
490
+ <div class="flex items-center justify-between mb-4">
491
+ <h3 class="text-sm font-bold text-[var(--ui-foreground)]">Add New Event</h3>
492
+ <button
493
+ type="button"
494
+ onclick={() => (modalOpen = false)}
495
+ class="rounded-full p-1 text-[var(--ui-muted-foreground)] hover:bg-[var(--ui-secondary)] hover:text-[var(--ui-foreground)]"
496
+ >
497
+ <X class="size-4" />
498
+ </button>
499
+ </div>
500
+
501
+ <div class="space-y-3.5 text-xs">
502
+ <div>
503
+ <label class="block mb-1 font-semibold text-[var(--ui-muted-foreground)]">Title</label>
504
+ <input
505
+ type="text"
506
+ bind:value={newEventTitle}
507
+ placeholder="Event title (e.g. Sprint Review)..."
508
+ class="h-9 w-full rounded-lg border border-[var(--ui-input)] bg-[var(--ui-background)] px-3 text-xs outline-none focus:border-[var(--ui-primary)]"
509
+ autofocus
510
+ />
511
+ </div>
512
+
513
+ <div class="grid grid-cols-2 gap-3">
514
+ <div>
515
+ <label class="block mb-1 font-semibold text-[var(--ui-muted-foreground)]">Start Time</label>
516
+ <input
517
+ type="time"
518
+ bind:value={newEventStartTime}
519
+ class="h-9 w-full rounded-lg border border-[var(--ui-input)] bg-[var(--ui-background)] px-2.5 text-xs outline-none focus:border-[var(--ui-primary)]"
520
+ />
521
+ </div>
522
+ <div>
523
+ <label class="block mb-1 font-semibold text-[var(--ui-muted-foreground)]">End Time</label>
524
+ <input
525
+ type="time"
526
+ bind:value={newEventEndTime}
527
+ class="h-9 w-full rounded-lg border border-[var(--ui-input)] bg-[var(--ui-background)] px-2.5 text-xs outline-none focus:border-[var(--ui-primary)]"
528
+ />
529
+ </div>
530
+ </div>
531
+
532
+ <div>
533
+ <label class="block mb-1.5 font-semibold text-[var(--ui-muted-foreground)]">Tag Color</label>
534
+ <div class="flex items-center gap-2">
535
+ {#each presetColors as col}
536
+ <button
537
+ type="button"
538
+ onclick={() => (newEventColor = col)}
539
+ class={cn(
540
+ 'size-6 rounded-full transition-transform hover:scale-110 cursor-pointer',
541
+ newEventColor === col && 'ring-2 ring-offset-2 ring-[var(--ui-primary)]'
542
+ )}
543
+ style="background-color: {col};"
544
+ aria-label="Select color {col}"
545
+ ></button>
546
+ {/each}
547
+ </div>
548
+ </div>
549
+
550
+ <div>
551
+ <label class="block mb-1 font-semibold text-[var(--ui-muted-foreground)]">Description (optional)</label>
552
+ <textarea
553
+ bind:value={newEventDescription}
554
+ rows={2}
555
+ placeholder="Notes, agenda, or link..."
556
+ class="w-full rounded-lg border border-[var(--ui-input)] bg-[var(--ui-background)] p-2.5 text-xs outline-none focus:border-[var(--ui-primary)]"
557
+ ></textarea>
558
+ </div>
559
+ </div>
560
+
561
+ <div class="mt-5 flex items-center justify-end gap-2 border-t border-[var(--ui-border)] pt-3">
562
+ <Button variant="outline" size="sm" onclick={() => (modalOpen = false)}>Cancel</Button>
563
+ <Button size="sm" onclick={handleSaveEvent}>Save Event</Button>
564
+ </div>
565
+ </div>
566
+ </div>
567
+ {/if}
568
+ </div>
@@ -0,0 +1,26 @@
1
+ export type CalendarView = 'month' | 'week' | 'day';
2
+ export type CalendarEvent = {
3
+ id: string;
4
+ title: string;
5
+ start: Date;
6
+ end: Date;
7
+ color?: string;
8
+ description?: string;
9
+ allDay?: boolean;
10
+ };
11
+ type $$ComponentProps = {
12
+ events?: CalendarEvent[];
13
+ view?: CalendarView;
14
+ currentDate?: Date;
15
+ locale?: string;
16
+ timeZone?: string;
17
+ firstDayOfWeek?: number;
18
+ enableEventModal?: boolean;
19
+ class?: string;
20
+ onEventClick?: (event: CalendarEvent) => void;
21
+ onDateClick?: (date: Date) => void;
22
+ onAddEvent?: (newEvent: CalendarEvent) => void;
23
+ };
24
+ declare const EventCalendar: import("svelte").Component<$$ComponentProps, {}, "events" | "view" | "currentDate">;
25
+ type EventCalendar = ReturnType<typeof EventCalendar>;
26
+ export default EventCalendar;
@@ -0,0 +1,7 @@
1
+ export { default as Calendar } from './Calendar.svelte';
2
+ export { default as DatePicker } from './DatePicker.svelte';
3
+ export { default as DateRangePicker } from './DateRangePicker.svelte';
4
+ export { default as EventCalendar } from './EventCalendar.svelte';
5
+ export type { CalendarDate } from './Calendar.svelte';
6
+ export type { DateRange } from './DateRangePicker.svelte';
7
+ export type { CalendarEvent, CalendarView } from './EventCalendar.svelte';
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { default as Calendar } from './Calendar.svelte';
2
+ export { default as DatePicker } from './DatePicker.svelte';
3
+ export { default as DateRangePicker } from './DateRangePicker.svelte';
4
+ export { default as EventCalendar } from './EventCalendar.svelte';
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@intinyagroup/calendar",
3
+ "version": "0.1.1-alpha.11",
4
+ "description": "Calendar and DatePicker components for intinyagroup UI",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./dist/index.d.ts",
9
+ "svelte": "./dist/index.js"
10
+ }
11
+ },
12
+ "files": [
13
+ "dist",
14
+ "README.md"
15
+ ],
16
+ "scripts": {
17
+ "build": "svelte-package -o dist",
18
+ "check": "echo 'no check'",
19
+ "dev": "svelte-package -o dist -w"
20
+ },
21
+ "dependencies": {
22
+ "@intinyagroup/ui": "0.1.1-alpha.9",
23
+ "date-fns": "^4.4.0"
24
+ },
25
+ "devDependencies": {
26
+ "@sveltejs/package": "^2.5.8",
27
+ "@sveltejs/vite-plugin-svelte": "^5.1.1",
28
+ "svelte": "^5.55.2",
29
+ "typescript": "^6.0.2"
30
+ },
31
+ "peerDependencies": {
32
+ "svelte": "^5.0.0"
33
+ },
34
+ "license": "MIT",
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/intinyagroup/ui.git",
38
+ "directory": "packages/calendar"
39
+ },
40
+ "publishConfig": {
41
+ "access": "public"
42
+ }
43
+ }