@intinyagroup/calendar 0.1.1-alpha.13 → 0.1.1-alpha.15

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.
@@ -273,6 +273,8 @@
273
273
  {@const isEdge = isStart || isEnd}
274
274
 
275
275
  <div
276
+ role="gridcell"
277
+ aria-selected={isEdge}
276
278
  class={cn(
277
279
  'relative py-0.5 flex items-center justify-center',
278
280
  inRange && !isEdge && 'bg-[var(--ui-primary)]/10',
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, Clock, Plus, X } from 'lucide-svelte';
2
+ import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, CalendarDays, Clock, Plus, X } from 'lucide-svelte';
3
3
  import { Button } from '@intinyagroup/ui';
4
4
  import { cn } from '@intinyagroup/ui/utils';
5
5
  import {
@@ -44,6 +44,7 @@
44
44
  onEventClick,
45
45
  onDateClick,
46
46
  onAddEvent,
47
+ onDragCreate,
47
48
  onEventReschedule,
48
49
  }: {
49
50
  events?: CalendarEvent[];
@@ -59,12 +60,35 @@
59
60
  onAddEvent?: (newEvent: CalendarEvent) => void;
60
61
  onEventReschedule?: (detail: { event: CalendarEvent; newStart: Date; newEnd: Date }) => void;
61
62
  onEventResize?: (detail: { event: CalendarEvent; newStart: Date; newEnd: Date }) => void;
63
+ onDragCreate?: (detail: { start: Date; end: Date }) => void;
62
64
  } = $props();
63
65
 
64
66
  let draggedEventId = $state<string | null>(null);
65
67
  let resizingEventId = $state<string | null>(null);
66
68
  let resizeStartY = $state<number>(0);
67
69
  let resizeStartDuration = $state<number>(0);
70
+ // Drag-to-create state
71
+ let dragCreateStart = $state<{ date: Date; hour: number; minute: number } | null>(null);
72
+ let dragCreateEnd = $state<{ date: Date; hour: number; minute: number } | null>(null);
73
+ let isDragCreating = $state(false);
74
+ let dragCreateColumn = $state<Date | null>(null);
75
+ // Mobile detection
76
+ let isMobile = $state(typeof window !== 'undefined' ? window.matchMedia('(max-width: 767px)').matches : false);
77
+ $effect(() => {
78
+ if (typeof window === 'undefined') return;
79
+ const mq = window.matchMedia('(max-width: 767px)');
80
+ isMobile = mq.matches;
81
+ const handler = (e: MediaQueryListEvent) => { isMobile = e.matches; };
82
+ mq.addEventListener('change', handler);
83
+ return () => mq.removeEventListener('change', handler);
84
+ });
85
+
86
+ // Mobile week: show only current day ± 1
87
+ const mobileWeekDays = $derived([
88
+ subDays(currentDate, 1),
89
+ currentDate,
90
+ addDays(currentDate, 1)
91
+ ]);
68
92
 
69
93
  function isMultiDayOrAllDay(ev: CalendarEvent): boolean {
70
94
  if (ev.allDay) return true;
@@ -137,6 +161,84 @@
137
161
  window.addEventListener('mousemove', onMouseMove);
138
162
  window.addEventListener('mouseup', onMouseUp);
139
163
  }
164
+ // Drag-to-create: snap to 15-minute increments
165
+ function snapTo15Minutes(minutes: number): number {
166
+ return Math.round(minutes / 15) * 15;
167
+ }
168
+
169
+ function handleDragCreateMouseDown(e: MouseEvent, date: Date, hour: number, containerEl: HTMLElement) {
170
+ // Only left click
171
+ if (e.button !== 0) return;
172
+ // Don't start drag-create if already dragging an event or resizing
173
+ if (draggedEventId || resizingEventId) return;
174
+ // Don't start if clicking on an event element
175
+ const target = e.target as HTMLElement;
176
+ if (target.closest('[role="button"]') || target.closest('.group\\/event')) return;
177
+
178
+ e.preventDefault();
179
+ e.stopPropagation();
180
+
181
+ const rect = containerEl.getBoundingClientRect();
182
+ const y = e.clientY - rect.top;
183
+ const hourHeight = containerEl.id === 'week-day-col' ? 56 : 64;
184
+ const minutesInHour = (y / hourHeight) * 60;
185
+ const minute = snapTo15Minutes(minutesInHour);
186
+ const clampedMinute = Math.min(45, Math.max(0, minute));
187
+
188
+ dragCreateStart = { date, hour, minute: clampedMinute };
189
+ dragCreateEnd = { date, hour, minute: clampedMinute + 15 };
190
+ dragCreateColumn = date;
191
+ isDragCreating = true;
192
+
193
+ function onMouseMove(moveEvent: MouseEvent) {
194
+ if (!isDragCreating || !dragCreateStart) return;
195
+ const moveY = moveEvent.clientY - rect.top;
196
+ const moveMinutesInHour = (moveY / hourHeight) * 60;
197
+ const snappedMinutes = snapTo15Minutes(moveMinutesInHour);
198
+ const clampedMinutes = Math.min(24 * 60, Math.max(0, snappedMinutes));
199
+
200
+ const totalStartMinutes = dragCreateStart.hour * 60 + dragCreateStart.minute;
201
+ let totalEndMinutes = Math.max(totalStartMinutes + 15, clampedMinutes);
202
+ totalEndMinutes = Math.min(24 * 60, totalEndMinutes);
203
+
204
+ const endHour = Math.floor(totalEndMinutes / 60);
205
+ const endMinute = totalEndMinutes % 60;
206
+ dragCreateEnd = { date, hour: endHour, minute: endMinute };
207
+ }
208
+
209
+ function onMouseUp() {
210
+ if (isDragCreating && dragCreateStart && dragCreateEnd) {
211
+ const startMinutes = dragCreateStart.hour * 60 + dragCreateStart.minute;
212
+ const endMinutes = dragCreateEnd.hour * 60 + dragCreateEnd.minute;
213
+
214
+ if (endMinutes - startMinutes >= 15) {
215
+ const startDate = new Date(date);
216
+ startDate.setHours(dragCreateStart.hour, dragCreateStart.minute, 0, 0);
217
+ const endDate = new Date(date);
218
+ endDate.setHours(dragCreateEnd.hour, dragCreateEnd.minute, 0, 0);
219
+
220
+ onDragCreate?.({ start: startDate, end: endDate });
221
+
222
+ if (enableEventModal) {
223
+ newEventDate = new Date(date);
224
+ newEventStartTime = `${String(dragCreateStart.hour).padStart(2, '0')}:${String(dragCreateStart.minute).padStart(2, '0')}`;
225
+ newEventEndTime = `${String(dragCreateEnd.hour).padStart(2, '0')}:${String(dragCreateEnd.minute).padStart(2, '0')}`;
226
+ modalOpen = true;
227
+ }
228
+ }
229
+ }
230
+
231
+ isDragCreating = false;
232
+ dragCreateStart = null;
233
+ dragCreateEnd = null;
234
+ dragCreateColumn = null;
235
+ window.removeEventListener('mousemove', onMouseMove);
236
+ window.removeEventListener('mouseup', onMouseUp);
237
+ }
238
+
239
+ window.addEventListener('mousemove', onMouseMove);
240
+ window.addEventListener('mouseup', onMouseUp);
241
+ }
140
242
 
141
243
  type LayoutEvent = CalendarEvent & {
142
244
  colIndex: number;
@@ -305,8 +407,9 @@
305
407
  return format(currentDate, 'MMMM yyyy', { locale: activeDateFnsLocale });
306
408
  }
307
409
  if (view === 'week') {
308
- const start = weekDays[0];
309
- const end = weekDays[6];
410
+ const displayDays = isMobile ? mobileWeekDays : weekDays;
411
+ const start = displayDays[0];
412
+ const end = displayDays[displayDays.length - 1];
310
413
  const sStr = format(start, 'dd MMM', { locale: activeDateFnsLocale });
311
414
  const eStr = format(end, 'dd MMM yyyy', { locale: activeDateFnsLocale });
312
415
  return `${sStr} - ${eStr}`;
@@ -365,10 +468,10 @@
365
468
 
366
469
  <div class={cn('relative flex flex-col rounded-xl border border-[var(--ui-border)] bg-[var(--ui-card)] shadow-xs overflow-hidden', className)}>
367
470
  <!-- Toolbar Header -->
368
- <div class="flex flex-wrap items-center justify-between gap-3 border-b border-[var(--ui-border)] p-4 sm:px-6">
369
- <div class="flex items-center gap-2">
471
+ <div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 border-b border-[var(--ui-border)] p-4 sm:px-6">
472
+ <div class="flex flex-col sm:flex-row sm:items-center gap-2">
370
473
  <h2 class="text-base font-bold text-[var(--ui-foreground)] capitalize">{titleHeader}</h2>
371
- <div class="flex items-center gap-1 ml-2">
474
+ <div class="flex items-center gap-1">
372
475
  <Button variant="outline" size="sm" class="size-8 p-0" onclick={goPrev}>
373
476
  <ChevronLeft class="size-4" />
374
477
  </Button>
@@ -379,7 +482,7 @@
379
482
  <ChevronRight class="size-4" />
380
483
  </Button>
381
484
  </div>
382
- <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)]">
485
+ <span class="hidden sm: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)]">
383
486
  {activeTimeZone}
384
487
  </span>
385
488
  </div>
@@ -391,37 +494,40 @@
391
494
  type="button"
392
495
  onclick={() => (view = 'month')}
393
496
  class={cn(
394
- 'rounded-md px-3 py-1 text-xs font-semibold transition-colors cursor-pointer',
497
+ 'rounded-md px-2 sm:px-3 py-1 text-xs font-semibold transition-colors cursor-pointer',
395
498
  view === 'month' ? 'bg-[var(--ui-card)] text-[var(--ui-foreground)] shadow-xs' : 'text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)]'
396
499
  )}
397
500
  >
398
- Month
501
+ <CalendarDays class="size-4 sm:hidden" />
502
+ <span class="hidden sm:inline">Month</span>
399
503
  </button>
400
504
  <button
401
505
  type="button"
402
506
  onclick={() => (view = 'week')}
403
507
  class={cn(
404
- 'rounded-md px-3 py-1 text-xs font-semibold transition-colors cursor-pointer',
508
+ 'rounded-md px-2 sm:px-3 py-1 text-xs font-semibold transition-colors cursor-pointer',
405
509
  view === 'week' ? 'bg-[var(--ui-card)] text-[var(--ui-foreground)] shadow-xs' : 'text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)]'
406
510
  )}
407
511
  >
408
- Week
512
+ <CalendarIcon class="size-4 sm:hidden" />
513
+ <span class="hidden sm:inline">Week</span>
409
514
  </button>
410
515
  <button
411
516
  type="button"
412
517
  onclick={() => (view = 'day')}
413
518
  class={cn(
414
- 'rounded-md px-3 py-1 text-xs font-semibold transition-colors cursor-pointer',
519
+ 'rounded-md px-2 sm:px-3 py-1 text-xs font-semibold transition-colors cursor-pointer',
415
520
  view === 'day' ? 'bg-[var(--ui-card)] text-[var(--ui-foreground)] shadow-xs' : 'text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)]'
416
521
  )}
417
522
  >
418
- Day
523
+ <Clock class="size-4 sm:hidden" />
524
+ <span class="hidden sm:inline">Day</span>
419
525
  </button>
420
526
  </div>
421
527
 
422
528
  {#if enableEventModal}
423
529
  <Button size="sm" class="gap-1.5 h-8 text-xs" onclick={() => openCreateModal(currentDate)}>
424
- <Plus class="size-3.5" /> Add Event
530
+ <Plus class="size-3.5" /> <span class="hidden sm:inline">Add Event</span>
425
531
  </Button>
426
532
  {/if}
427
533
  </div>
@@ -455,7 +561,7 @@
455
561
  handleEventDrop(date);
456
562
  }}
457
563
  class={cn(
458
- 'min-h-[110px] p-1.5 flex flex-col gap-1 transition-colors hover:bg-[var(--ui-secondary)]/25 cursor-pointer select-none',
564
+ 'min-h-[80px] md:min-h-[110px] p-1 md:p-1.5 flex flex-col gap-1 transition-colors hover:bg-[var(--ui-secondary)]/25 cursor-pointer select-none',
459
565
  !currentMonth && 'bg-[var(--ui-muted)]/15 opacity-50'
460
566
  )}
461
567
  >
@@ -477,7 +583,7 @@
477
583
 
478
584
  <!-- Event pills with spanning indicator -->
479
585
  <div class="flex flex-col gap-1 overflow-hidden">
480
- {#each dayEvents.slice(0, 3) as ev (ev.id)}
586
+ {#each dayEvents.slice(0, isMobile ? 2 : 3) as ev (ev.id)}
481
587
  {@const isSpanning = isMultiDayOrAllDay(ev)}
482
588
  {@const isStartDay = isSameDay(new Date(ev.start), date)}
483
589
  {@const isEndDay = isSameDay(new Date(ev.end), date)}
@@ -495,7 +601,7 @@
495
601
  onEventClick?.(ev);
496
602
  }}
497
603
  class={cn(
498
- 'truncate px-1.5 py-0.5 text-left text-[11px] font-medium transition-opacity hover:opacity-85 text-white shadow-2xs cursor-grab active:cursor-grabbing',
604
+ 'truncate px-1.5 py-0.5 text-left text-[10px] md:text-[11px] font-medium transition-opacity hover:opacity-85 text-white shadow-2xs cursor-grab active:cursor-grabbing',
499
605
  isSpanning ? 'rounded-none' : 'rounded',
500
606
  isSpanning && isStartDay && 'rounded-l-md',
501
607
  isSpanning && isEndDay && 'rounded-r-md',
@@ -511,9 +617,9 @@
511
617
  </button>
512
618
  {/each}
513
619
 
514
- {#if dayEvents.length > 3}
620
+ {#if dayEvents.length > (isMobile ? 2 : 3)}
515
621
  <span class="text-[10px] font-semibold text-[var(--ui-muted-foreground)] px-1">
516
- +{dayEvents.length - 3} more
622
+ +{dayEvents.length - (isMobile ? 2 : 3)} more
517
623
  </span>
518
624
  {/if}
519
625
  </div>
@@ -522,11 +628,11 @@
522
628
  </div>
523
629
  {:else if view === 'week'}
524
630
  <!-- WEEK VIEW -->
525
- <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">
631
+ <div class="grid grid-cols-4 md: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">
526
632
  <div class="col-span-1">Time</div>
527
- {#each weekDays as d, idx}
633
+ {#each (isMobile ? mobileWeekDays : weekDays) as d}
528
634
  <div class="col-span-1 flex flex-col items-center">
529
- <span class="capitalize">{dayNames[idx]}</span>
635
+ <span class="capitalize">{format(d, 'EEE', { locale: activeDateFnsLocale })}</span>
530
636
  <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)]' : '')}>
531
637
  {d.getDate()}
532
638
  </span>
@@ -535,13 +641,13 @@
535
641
  </div>
536
642
 
537
643
  <!-- All-day header slot (FullCalendar 1:1) -->
538
- {@const allDayEvents = getAllDayEventsForWeek(weekDays)}
644
+ {@const allDayEvents = getAllDayEventsForWeek(isMobile ? mobileWeekDays : weekDays)}
539
645
  {#if allDayEvents.length > 0}
540
- <div class="grid grid-cols-8 border-b border-[var(--ui-border)] bg-[var(--ui-secondary)]/10 text-xs divide-x divide-[var(--ui-border)] py-1.5">
646
+ <div class="grid grid-cols-4 md:grid-cols-8 border-b border-[var(--ui-border)] bg-[var(--ui-secondary)]/10 text-xs divide-x divide-[var(--ui-border)] py-1.5">
541
647
  <div class="col-span-1 text-right pr-2 text-[10px] uppercase tracking-wider font-semibold text-[var(--ui-muted-foreground)] flex items-center justify-end">
542
648
  all-day
543
649
  </div>
544
- <div class="col-span-7 px-2 flex flex-col gap-1">
650
+ <div class="col-span-3 md:col-span-7 px-2 flex flex-col gap-1">
545
651
  {#each allDayEvents as ev (ev.id)}
546
652
  <button
547
653
  type="button"
@@ -559,7 +665,7 @@
559
665
  </div>
560
666
  {/if}
561
667
 
562
- <div class="grid grid-cols-8 divide-x divide-[var(--ui-border)] max-h-[600px] overflow-y-auto">
668
+ <div class="grid grid-cols-4 md:grid-cols-8 divide-x divide-[var(--ui-border)] max-h-[600px] overflow-y-auto">
563
669
  <!-- Hours Column -->
564
670
  <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)]">
565
671
  {#each hours as hour}
@@ -567,8 +673,8 @@
567
673
  {/each}
568
674
  </div>
569
675
 
570
- <!-- 7 Days Grid Columns -->
571
- {#each weekDays as d}
676
+ <!-- Day Grid Columns -->
677
+ {#each (isMobile ? mobileWeekDays : weekDays) as d}
572
678
  {@const dayEvents = getTimedEventsForDay(d)}
573
679
  {@const layoutEvents = computeOverlappingLayout(dayEvents)}
574
680
  {@const isCurrentDay = isToday(d)}
@@ -578,14 +684,15 @@
578
684
 
579
685
  <!-- svelte-ignore a11y_no_static_element_interactions -->
580
686
  <!-- svelte-ignore a11y_click_events_have_key_events -->
581
- <div
687
+ <div id="week-day-col"
582
688
  onclick={() => openCreateModal(d)}
583
689
  class="col-span-1 divide-y divide-[var(--ui-border)]/50 relative hover:bg-[var(--ui-secondary)]/10 cursor-pointer select-none"
584
690
  >
585
691
  {#each hours as hour}
586
692
  <!-- svelte-ignore a11y_no_static_element_interactions -->
587
693
  <div
588
- class="h-14 transition-colors hover:bg-[var(--ui-primary)]/10"
694
+ class="h-14 transition-colors hover:bg-[var(--ui-primary)]/10 pointer-events-auto"
695
+ onmousedown={(e) => handleDragCreateMouseDown(e, d, hour, e.currentTarget.closest('[id="week-day-col"]')!)}
589
696
  ondragover={(e) => e.preventDefault()}
590
697
  ondrop={(e) => {
591
698
  e.preventDefault();
@@ -651,9 +758,30 @@
651
758
  ></div>
652
759
  </div>
653
760
  {/each}
761
+ <!-- Drag-to-create preview -->
762
+ {#if isDragCreating && dragCreateStart && dragCreateEnd && dragCreateColumn && isSameDay(dragCreateColumn, d)}
763
+ {@const startTotalMin = dragCreateStart.hour * 60 + dragCreateStart.minute}
764
+ {@const endTotalMin = dragCreateEnd.hour * 60 + dragCreateEnd.minute}
765
+ {@const previewTop = (startTotalMin / 60) * 56}
766
+ {@const previewHeight = Math.max(14, ((endTotalMin - startTotalMin) / 60) * 56)}
767
+ <div
768
+ class="absolute inset-x-0.5 z-20 rounded bg-blue-500/30 border border-blue-400/50 pointer-events-none flex items-start justify-center pt-0.5"
769
+ style="top: {previewTop}px; height: {previewHeight}px;"
770
+ >
771
+ <span class="text-[9px] font-semibold text-blue-700 select-none">
772
+ {String(dragCreateStart.hour).padStart(2, '0')}:{String(dragCreateStart.minute).padStart(2, '0')} - {String(dragCreateEnd.hour).padStart(2, '0')}:{String(dragCreateEnd.minute).padStart(2, '0')}
773
+ </span>
774
+ </div>
775
+ {/if}
654
776
  </div>
655
777
  {/each}
656
778
  </div>
779
+ <!-- Mobile swipe hint -->
780
+ {#if isMobile}
781
+ <div class="px-4 py-2 text-center text-[11px] text-[var(--ui-muted-foreground)] border-t border-[var(--ui-border)]">
782
+ Swipe to see more days
783
+ </div>
784
+ {/if}
657
785
  {:else}
658
786
  <!-- DAY VIEW -->
659
787
  <div class="p-3 border-b border-[var(--ui-border)] bg-[var(--ui-secondary)]/20 flex items-center justify-between">
@@ -675,12 +803,14 @@
675
803
 
676
804
  <!-- svelte-ignore a11y_no_static_element_interactions -->
677
805
  <!-- svelte-ignore a11y_click_events_have_key_events -->
678
- <div
806
+ <div id="day-view-col"
679
807
  onclick={() => openCreateModal(currentDate)}
680
808
  class="col-span-10 divide-y divide-[var(--ui-border)]/50 relative p-1 hover:bg-[var(--ui-secondary)]/10 cursor-pointer select-none"
681
809
  >
682
- {#each hours as _}
683
- <div class="h-16"></div>
810
+ {#each hours as hour}
811
+ <div class="h-16 pointer-events-auto"
812
+ onmousedown={(e) => handleDragCreateMouseDown(e, currentDate, hour, e.currentTarget.closest('[id="day-view-col"]')!)}
813
+ ></div>
684
814
  {/each}
685
815
 
686
816
  <!-- Google Calendar style Current Time Red Line -->
@@ -740,6 +870,21 @@
740
870
  ></div>
741
871
  </div>
742
872
  {/each}
873
+ <!-- Drag-to-create preview -->
874
+ {#if isDragCreating && dragCreateStart && dragCreateEnd && isSameDay(dragCreateColumn!, currentDate)}
875
+ {@const startTotalMin = dragCreateStart.hour * 60 + dragCreateStart.minute}
876
+ {@const endTotalMin = dragCreateEnd.hour * 60 + dragCreateEnd.minute}
877
+ {@const previewTop = (startTotalMin / 60) * 64}
878
+ {@const previewHeight = Math.max(16, ((endTotalMin - startTotalMin) / 60) * 64)}
879
+ <div
880
+ class="absolute inset-x-1 z-20 rounded-lg bg-blue-500/30 border border-blue-400/50 pointer-events-none flex items-start justify-center pt-1"
881
+ style="top: {previewTop}px; height: {previewHeight}px;"
882
+ >
883
+ <span class="text-[10px] font-semibold text-blue-700 select-none">
884
+ {String(dragCreateStart.hour).padStart(2, '0')}:{String(dragCreateStart.minute).padStart(2, '0')} - {String(dragCreateEnd.hour).padStart(2, '0')}:{String(dragCreateEnd.minute).padStart(2, '0')}
885
+ </span>
886
+ </div>
887
+ {/if}
743
888
  </div>
744
889
  </div>
745
890
  {/if}
@@ -30,6 +30,10 @@ type $$ComponentProps = {
30
30
  newStart: Date;
31
31
  newEnd: Date;
32
32
  }) => void;
33
+ onDragCreate?: (detail: {
34
+ start: Date;
35
+ end: Date;
36
+ }) => void;
33
37
  };
34
38
  declare const EventCalendar: import("svelte").Component<$$ComponentProps, {}, "events" | "view" | "currentDate">;
35
39
  type EventCalendar = ReturnType<typeof EventCalendar>;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,129 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { render, screen, fireEvent } from '@testing-library/svelte';
3
+ import EventCalendar from './EventCalendar.svelte';
4
+ describe('EventCalendar', () => {
5
+ beforeEach(() => {
6
+ vi.useFakeTimers();
7
+ vi.setSystemTime(new Date('2025-06-15T12:00:00'));
8
+ });
9
+ afterEach(() => {
10
+ vi.useRealTimers();
11
+ });
12
+ it('renders the calendar component with correct month title', () => {
13
+ render(EventCalendar);
14
+ // Title uses full month name: "MMMM yyyy"
15
+ expect(screen.getByText('June 2025')).toBeInTheDocument();
16
+ });
17
+ it('displays day-of-week headers', () => {
18
+ render(EventCalendar);
19
+ expect(screen.getByText('Sun')).toBeInTheDocument();
20
+ expect(screen.getByText('Mon')).toBeInTheDocument();
21
+ expect(screen.getByText('Tue')).toBeInTheDocument();
22
+ expect(screen.getByText('Wed')).toBeInTheDocument();
23
+ expect(screen.getByText('Thu')).toBeInTheDocument();
24
+ expect(screen.getByText('Fri')).toBeInTheDocument();
25
+ expect(screen.getByText('Sat')).toBeInTheDocument();
26
+ });
27
+ it('renders month view with day grid', () => {
28
+ render(EventCalendar);
29
+ // Should show days of June 2025. Use getAllByText for numbers that appear
30
+ // multiple times in the month grid (e.g. from previous/next month overflow).
31
+ expect(screen.getAllByText('15').length).toBeGreaterThanOrEqual(1);
32
+ expect(screen.getAllByText('30').length).toBeGreaterThanOrEqual(1);
33
+ expect(screen.getAllByText('1').length).toBeGreaterThanOrEqual(1);
34
+ });
35
+ it('renders events in the calendar', () => {
36
+ const events = [
37
+ {
38
+ id: '1',
39
+ title: 'Team Meeting',
40
+ start: new Date('2025-06-15T09:00:00'),
41
+ end: new Date('2025-06-15T10:00:00'),
42
+ },
43
+ ];
44
+ render(EventCalendar, { props: { events } });
45
+ expect(screen.getByText('Team Meeting')).toBeInTheDocument();
46
+ });
47
+ it('renders multiple events on the same day', () => {
48
+ const events = [
49
+ {
50
+ id: '1',
51
+ title: 'Morning Standup',
52
+ start: new Date('2025-06-15T09:00:00'),
53
+ end: new Date('2025-06-15T09:30:00'),
54
+ },
55
+ {
56
+ id: '2',
57
+ title: 'Lunch Break',
58
+ start: new Date('2025-06-15T12:00:00'),
59
+ end: new Date('2025-06-15T13:00:00'),
60
+ },
61
+ ];
62
+ render(EventCalendar, { props: { events } });
63
+ expect(screen.getByText('Morning Standup')).toBeInTheDocument();
64
+ expect(screen.getByText('Lunch Break')).toBeInTheDocument();
65
+ });
66
+ it('navigates to the previous month', async () => {
67
+ render(EventCalendar);
68
+ // Prev button is a Button with ChevronLeft icon; find by role
69
+ const prevBtn = screen.getAllByRole('button')[0];
70
+ await fireEvent.click(prevBtn);
71
+ expect(screen.getByText('May 2025')).toBeInTheDocument();
72
+ });
73
+ it('navigates to the next month', async () => {
74
+ render(EventCalendar);
75
+ // Next button is the third button (prev=0, today=1, next=2)
76
+ const nextBtn = screen.getAllByRole('button')[2];
77
+ await fireEvent.click(nextBtn);
78
+ expect(screen.getByText('July 2025')).toBeInTheDocument();
79
+ });
80
+ it('navigates back to today', async () => {
81
+ render(EventCalendar);
82
+ // Navigate to next month first
83
+ const nextBtn = screen.getAllByRole('button')[2];
84
+ await fireEvent.click(nextBtn);
85
+ expect(screen.getByText('July 2025')).toBeInTheDocument();
86
+ // Click Today button (text content is "Today")
87
+ const todayBtn = screen.getByText('Today');
88
+ await fireEvent.click(todayBtn);
89
+ expect(screen.getByText('June 2025')).toBeInTheDocument();
90
+ });
91
+ it('displays view switcher buttons', () => {
92
+ render(EventCalendar);
93
+ expect(screen.getByText('Month')).toBeInTheDocument();
94
+ expect(screen.getByText('Week')).toBeInTheDocument();
95
+ expect(screen.getByText('Day')).toBeInTheDocument();
96
+ });
97
+ it('shows event with color indicator', () => {
98
+ const events = [
99
+ {
100
+ id: '1',
101
+ title: 'Important Call',
102
+ start: new Date('2025-06-15T14:00:00'),
103
+ end: new Date('2025-06-15T15:00:00'),
104
+ color: '#dc2626',
105
+ },
106
+ ];
107
+ render(EventCalendar, { props: { events } });
108
+ expect(screen.getByText('Important Call')).toBeInTheDocument();
109
+ });
110
+ it('renders empty state without crashing', () => {
111
+ render(EventCalendar, { props: { events: [] } });
112
+ expect(screen.getByText('June 2025')).toBeInTheDocument();
113
+ });
114
+ it('renders all-day events (may appear in both grid and all-day header)', () => {
115
+ const events = [
116
+ {
117
+ id: '1',
118
+ title: 'Company Holiday',
119
+ start: new Date('2025-06-15T00:00:00'),
120
+ end: new Date('2025-06-16T00:00:00'),
121
+ allDay: true,
122
+ },
123
+ ];
124
+ render(EventCalendar, { props: { events } });
125
+ // All-day events appear in both the month grid and the all-day section
126
+ const matches = screen.getAllByText('Company Holiday');
127
+ expect(matches.length).toBeGreaterThanOrEqual(1);
128
+ });
129
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intinyagroup/calendar",
3
- "version": "0.1.1-alpha.13",
3
+ "version": "0.1.1-alpha.15",
4
4
  "description": "Calendar and DatePicker components for intinyagroup UI",
5
5
  "type": "module",
6
6
  "exports": {
@@ -16,10 +16,11 @@
16
16
  "scripts": {
17
17
  "build": "svelte-package -o dist",
18
18
  "check": "echo 'no check'",
19
- "dev": "svelte-package -o dist -w"
19
+ "dev": "svelte-package -o dist -w",
20
+ "test": "vitest run"
20
21
  },
21
22
  "dependencies": {
22
- "@intinyagroup/ui": "0.1.1-alpha.11",
23
+ "@intinyagroup/ui": "0.1.1-alpha.13",
23
24
  "date-fns": "^4.4.0",
24
25
  "lucide-svelte": "^1.0.1"
25
26
  },