@intinyagroup/calendar 0.1.1-alpha.12 → 0.1.1-alpha.14

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',
@@ -44,6 +44,7 @@
44
44
  onEventClick,
45
45
  onDateClick,
46
46
  onAddEvent,
47
+ onDragCreate,
47
48
  onEventReschedule,
48
49
  }: {
49
50
  events?: CalendarEvent[];
@@ -58,18 +59,39 @@
58
59
  onDateClick?: (date: Date) => void;
59
60
  onAddEvent?: (newEvent: CalendarEvent) => void;
60
61
  onEventReschedule?: (detail: { event: CalendarEvent; newStart: Date; newEnd: Date }) => void;
62
+ onEventResize?: (detail: { event: CalendarEvent; newStart: Date; newEnd: Date }) => void;
63
+ onDragCreate?: (detail: { start: Date; end: Date }) => void;
61
64
  } = $props();
62
65
 
63
66
  let draggedEventId = $state<string | null>(null);
67
+ let resizingEventId = $state<string | null>(null);
68
+ let resizeStartY = $state<number>(0);
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
+
76
+ function isMultiDayOrAllDay(ev: CalendarEvent): boolean {
77
+ if (ev.allDay) return true;
78
+ const s = new Date(ev.start);
79
+ const e = new Date(ev.end);
80
+ return !isSameDay(s, e) || (e.getTime() - s.getTime() >= 24 * 60 * 60 * 1000);
81
+ }
64
82
 
65
- function handleEventDrop(targetDate: Date, targetHour: number) {
83
+ function handleEventDrop(targetDate: Date, targetHour?: number) {
66
84
  if (!draggedEventId) return;
67
85
  const ev = events.find((e) => e.id === draggedEventId);
68
86
  if (!ev) return;
69
87
 
70
88
  const origDurationMs = new Date(ev.end).getTime() - new Date(ev.start).getTime();
71
89
  const newStart = new Date(targetDate);
72
- newStart.setHours(targetHour, 0, 0, 0);
90
+ if (targetHour !== undefined) {
91
+ newStart.setHours(targetHour, 0, 0, 0);
92
+ } else {
93
+ newStart.setHours(new Date(ev.start).getHours(), new Date(ev.start).getMinutes(), 0, 0);
94
+ }
73
95
  const newEnd = new Date(newStart.getTime() + origDurationMs);
74
96
 
75
97
  events = events.map((e) =>
@@ -79,15 +101,193 @@
79
101
  onEventReschedule?.({ event: ev, newStart, newEnd });
80
102
  draggedEventId = null;
81
103
  }
82
- const activeDateFnsLocale = $derived(locale.startsWith('id') ? localeId : localeEn);
83
- const weekStartsOn = $derived(firstDayOfWeek as 0 | 1 | 2 | 3 | 4 | 5 | 6);
104
+
105
+ function handleResizeStart(e: MouseEvent, ev: CalendarEvent) {
106
+ e.stopPropagation();
107
+ e.preventDefault();
108
+ resizingEventId = ev.id;
109
+ resizeStartY = e.clientY;
110
+ resizeStartDuration = new Date(ev.end).getTime() - new Date(ev.start).getTime();
111
+
112
+ function onMouseMove(moveEvent: MouseEvent) {
113
+ if (!resizingEventId) return;
114
+ const deltaY = moveEvent.clientY - resizeStartY;
115
+ // 56px per hour => deltaY / 56 hours
116
+ const deltaHours = deltaY / 56;
117
+ // Snap to 15 minute increments (0.25h = 15 * 60 * 1000 ms)
118
+ const snapIntervalMs = 15 * 60 * 1000;
119
+ const rawDeltaMs = deltaHours * 60 * 60 * 1000;
120
+ const snappedDeltaMs = Math.round(rawDeltaMs / snapIntervalMs) * snapIntervalMs;
121
+ const newDurationMs = Math.max(15 * 60 * 1000, resizeStartDuration + snappedDeltaMs);
122
+
123
+ events = events.map((item) => {
124
+ if (item.id === resizingEventId) {
125
+ const s = new Date(item.start);
126
+ return { ...item, end: new Date(s.getTime() + newDurationMs) };
127
+ }
128
+ return item;
129
+ });
130
+ }
131
+
132
+ function onMouseUp() {
133
+ if (resizingEventId) {
134
+ const finished = events.find((item) => item.id === resizingEventId);
135
+ if (finished) {
136
+ onEventResize?.({ event: finished, newStart: new Date(finished.start), newEnd: new Date(finished.end) });
137
+ }
138
+ }
139
+ resizingEventId = null;
140
+ window.removeEventListener('mousemove', onMouseMove);
141
+ window.removeEventListener('mouseup', onMouseUp);
142
+ }
143
+
144
+ window.addEventListener('mousemove', onMouseMove);
145
+ window.addEventListener('mouseup', onMouseUp);
146
+ }
147
+ // Drag-to-create: snap to 15-minute increments
148
+ function snapTo15Minutes(minutes: number): number {
149
+ return Math.round(minutes / 15) * 15;
150
+ }
151
+
152
+ function handleDragCreateMouseDown(e: MouseEvent, date: Date, hour: number, containerEl: HTMLElement) {
153
+ // Only left click
154
+ if (e.button !== 0) return;
155
+ // Don't start drag-create if already dragging an event or resizing
156
+ if (draggedEventId || resizingEventId) return;
157
+ // Don't start if clicking on an event element
158
+ const target = e.target as HTMLElement;
159
+ if (target.closest('[role="button"]') || target.closest('.group\\/event')) return;
160
+
161
+ e.preventDefault();
162
+ e.stopPropagation();
163
+
164
+ const rect = containerEl.getBoundingClientRect();
165
+ const y = e.clientY - rect.top;
166
+ const hourHeight = containerEl.id === 'week-day-col' ? 56 : 64;
167
+ const minutesInHour = (y / hourHeight) * 60;
168
+ const minute = snapTo15Minutes(minutesInHour);
169
+ const clampedMinute = Math.min(45, Math.max(0, minute));
170
+
171
+ dragCreateStart = { date, hour, minute: clampedMinute };
172
+ dragCreateEnd = { date, hour, minute: clampedMinute + 15 };
173
+ dragCreateColumn = date;
174
+ isDragCreating = true;
175
+
176
+ function onMouseMove(moveEvent: MouseEvent) {
177
+ if (!isDragCreating || !dragCreateStart) return;
178
+ const moveY = moveEvent.clientY - rect.top;
179
+ const moveMinutesInHour = (moveY / hourHeight) * 60;
180
+ const snappedMinutes = snapTo15Minutes(moveMinutesInHour);
181
+ const clampedMinutes = Math.min(24 * 60, Math.max(0, snappedMinutes));
182
+
183
+ const totalStartMinutes = dragCreateStart.hour * 60 + dragCreateStart.minute;
184
+ let totalEndMinutes = Math.max(totalStartMinutes + 15, clampedMinutes);
185
+ totalEndMinutes = Math.min(24 * 60, totalEndMinutes);
186
+
187
+ const endHour = Math.floor(totalEndMinutes / 60);
188
+ const endMinute = totalEndMinutes % 60;
189
+ dragCreateEnd = { date, hour: endHour, minute: endMinute };
190
+ }
191
+
192
+ function onMouseUp() {
193
+ if (isDragCreating && dragCreateStart && dragCreateEnd) {
194
+ const startMinutes = dragCreateStart.hour * 60 + dragCreateStart.minute;
195
+ const endMinutes = dragCreateEnd.hour * 60 + dragCreateEnd.minute;
196
+
197
+ if (endMinutes - startMinutes >= 15) {
198
+ const startDate = new Date(date);
199
+ startDate.setHours(dragCreateStart.hour, dragCreateStart.minute, 0, 0);
200
+ const endDate = new Date(date);
201
+ endDate.setHours(dragCreateEnd.hour, dragCreateEnd.minute, 0, 0);
202
+
203
+ onDragCreate?.({ start: startDate, end: endDate });
204
+
205
+ if (enableEventModal) {
206
+ newEventDate = new Date(date);
207
+ newEventStartTime = `${String(dragCreateStart.hour).padStart(2, '0')}:${String(dragCreateStart.minute).padStart(2, '0')}`;
208
+ newEventEndTime = `${String(dragCreateEnd.hour).padStart(2, '0')}:${String(dragCreateEnd.minute).padStart(2, '0')}`;
209
+ modalOpen = true;
210
+ }
211
+ }
212
+ }
213
+
214
+ isDragCreating = false;
215
+ dragCreateStart = null;
216
+ dragCreateEnd = null;
217
+ dragCreateColumn = null;
218
+ window.removeEventListener('mousemove', onMouseMove);
219
+ window.removeEventListener('mouseup', onMouseUp);
220
+ }
221
+
222
+ window.addEventListener('mousemove', onMouseMove);
223
+ window.addEventListener('mouseup', onMouseUp);
224
+ }
225
+
226
+ type LayoutEvent = CalendarEvent & {
227
+ colIndex: number;
228
+ colTotal: number;
229
+ };
230
+
231
+ function computeOverlappingLayout(dayEvs: CalendarEvent[]): LayoutEvent[] {
232
+ // Sort events by start time, then longer events first
233
+ const sorted = [...dayEvs].sort((a, b) => {
234
+ const diff = new Date(a.start).getTime() - new Date(b.start).getTime();
235
+ if (diff !== 0) return diff;
236
+ return (new Date(b.end).getTime() - new Date(b.start).getTime()) - (new Date(a.end).getTime() - new Date(a.start).getTime());
237
+ });
238
+
239
+ const columns: CalendarEvent[][] = [];
240
+ const layoutMap = new Map<string, { colIndex: number }>();
241
+
242
+ for (const ev of sorted) {
243
+ let placed = false;
244
+ for (let i = 0; i < columns.length; i++) {
245
+ const lastInCol = columns[i][columns[i].length - 1];
246
+ if (new Date(lastInCol.end).getTime() <= new Date(ev.start).getTime()) {
247
+ columns[i].push(ev);
248
+ layoutMap.set(ev.id, { colIndex: i });
249
+ placed = true;
250
+ break;
251
+ }
252
+ }
253
+ if (!placed) {
254
+ columns.push([ev]);
255
+ layoutMap.set(ev.id, { colIndex: columns.length - 1 });
256
+ }
257
+ }
258
+
259
+ // Determine overlapping groups
260
+ return sorted.map((ev) => {
261
+ const colInfo = layoutMap.get(ev.id) || { colIndex: 0 };
262
+ // Count how many columns overlap with this event
263
+ let overlappingCols = 1;
264
+ for (let c = 0; c < columns.length; c++) {
265
+ const hasOverlap = columns[c].some(
266
+ (other) =>
267
+ new Date(other.start).getTime() < new Date(ev.end).getTime() &&
268
+ new Date(other.end).getTime() > new Date(ev.start).getTime()
269
+ );
270
+ if (hasOverlap && c + 1 > overlappingCols) {
271
+ overlappingCols = c + 1;
272
+ }
273
+ }
274
+
275
+ return {
276
+ ...ev,
277
+ colIndex: colInfo.colIndex,
278
+ colTotal: Math.max(1, overlappingCols),
279
+ };
280
+ });
281
+ }
84
282
 
85
283
  const activeTimeZone = $derived(
86
284
  timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
87
285
  );
88
286
 
89
- const hours = Array.from({ length: 24 }, (_, i) => i);
287
+ const activeDateFnsLocale = $derived(locale.startsWith('id') ? localeId : localeEn);
288
+ const weekStartsOn = $derived(firstDayOfWeek as 0 | 1 | 2 | 3 | 4 | 5 | 6);
90
289
 
290
+ const hours = Array.from({ length: 24 }, (_, i) => i);
91
291
  // Day names localized and rotated according to firstDayOfWeek via date-fns
92
292
  const dayNames = $derived.by(() => {
93
293
  const start = startOfWeek(currentDate, { weekStartsOn });
@@ -137,7 +337,26 @@
137
337
  });
138
338
 
139
339
  function getEventsForDay(d: Date): CalendarEvent[] {
140
- return events.filter((ev) => isSameDay(new Date(ev.start), d));
340
+ return events.filter((ev) => {
341
+ if (isSameDay(new Date(ev.start), d)) return true;
342
+ // Multi-day spanning: check if date falls between start and end
343
+ const s = new Date(ev.start);
344
+ const e = new Date(ev.end);
345
+ return d >= s && d <= e;
346
+ });
347
+ }
348
+
349
+ function getTimedEventsForDay(d: Date): CalendarEvent[] {
350
+ return events.filter((ev) => !isMultiDayOrAllDay(ev) && isSameDay(new Date(ev.start), d));
351
+ }
352
+
353
+ function getAllDayEventsForWeek(days: Date[]): CalendarEvent[] {
354
+ return events.filter((ev) => {
355
+ if (!isMultiDayOrAllDay(ev)) return false;
356
+ const s = new Date(ev.start);
357
+ const e = new Date(ev.end);
358
+ return days.some((d) => isSameDay(d, s) || isSameDay(d, e) || (d > s && d < e));
359
+ });
141
360
  }
142
361
 
143
362
  function getTimeInZone(date: Date): { hours: number; minutes: number } {
@@ -314,12 +533,18 @@
314
533
  onDateClick?.(date);
315
534
  openCreateModal(date);
316
535
  }}
536
+ ondragover={(e) => e.preventDefault()}
537
+ ondrop={(e) => {
538
+ e.preventDefault();
539
+ e.stopPropagation();
540
+ handleEventDrop(date);
541
+ }}
317
542
  class={cn(
318
- 'min-h-[100px] p-2 flex flex-col gap-1 transition-colors hover:bg-[var(--ui-secondary)]/25 cursor-pointer',
543
+ 'min-h-[110px] p-1.5 flex flex-col gap-1 transition-colors hover:bg-[var(--ui-secondary)]/25 cursor-pointer select-none',
319
544
  !currentMonth && 'bg-[var(--ui-muted)]/15 opacity-50'
320
545
  )}
321
546
  >
322
- <div class="flex items-center justify-between mb-1">
547
+ <div class="flex items-center justify-between mb-0.5 px-1">
323
548
  <span
324
549
  class={cn(
325
550
  'text-xs font-semibold size-6 flex items-center justify-center rounded-full',
@@ -335,27 +560,44 @@
335
560
  {/if}
336
561
  </div>
337
562
 
338
- <!-- Event pills -->
563
+ <!-- Event pills with spanning indicator -->
339
564
  <div class="flex flex-col gap-1 overflow-hidden">
340
565
  {#each dayEvents.slice(0, 3) as ev (ev.id)}
566
+ {@const isSpanning = isMultiDayOrAllDay(ev)}
567
+ {@const isStartDay = isSameDay(new Date(ev.start), date)}
568
+ {@const isEndDay = isSameDay(new Date(ev.end), date)}
569
+
341
570
  <button
342
571
  type="button"
572
+ draggable="true"
573
+ ondragstart={(e) => {
574
+ e.stopPropagation();
575
+ draggedEventId = ev.id;
576
+ e.dataTransfer?.setData('text/plain', ev.id);
577
+ }}
343
578
  onclick={(e) => {
344
579
  e.stopPropagation();
345
580
  onEventClick?.(ev);
346
581
  }}
347
582
  class={cn(
348
- 'truncate rounded px-1.5 py-0.5 text-left text-[11px] font-medium transition-opacity hover:opacity-85 text-white shadow-2xs',
349
- ev.color ? '' : 'bg-[var(--ui-primary)]'
583
+ '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',
584
+ isSpanning ? 'rounded-none' : 'rounded',
585
+ isSpanning && isStartDay && 'rounded-l-md',
586
+ isSpanning && isEndDay && 'rounded-r-md',
587
+ draggedEventId === ev.id ? 'opacity-40 ring-2 ring-white' : ''
350
588
  )}
351
- style={ev.color ? `background-color: ${ev.color};` : undefined}
589
+ style="background-color: {ev.color || 'var(--ui-primary)'};"
352
590
  >
353
- {formatTime(ev.start)} {ev.title}
591
+ {#if isSpanning}
592
+ <span class="font-semibold">{ev.title}</span>
593
+ {:else}
594
+ <span class="opacity-80 font-normal">{formatTime(ev.start)}</span> {ev.title}
595
+ {/if}
354
596
  </button>
355
597
  {/each}
356
598
 
357
599
  {#if dayEvents.length > 3}
358
- <span class="text-[10px] font-semibold text-[var(--ui-muted-foreground)] pl-1">
600
+ <span class="text-[10px] font-semibold text-[var(--ui-muted-foreground)] px-1">
359
601
  +{dayEvents.length - 3} more
360
602
  </span>
361
603
  {/if}
@@ -377,6 +619,31 @@
377
619
  {/each}
378
620
  </div>
379
621
 
622
+ <!-- All-day header slot (FullCalendar 1:1) -->
623
+ {@const allDayEvents = getAllDayEventsForWeek(weekDays)}
624
+ {#if allDayEvents.length > 0}
625
+ <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">
626
+ <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">
627
+ all-day
628
+ </div>
629
+ <div class="col-span-7 px-2 flex flex-col gap-1">
630
+ {#each allDayEvents as ev (ev.id)}
631
+ <button
632
+ type="button"
633
+ onclick={(e) => {
634
+ e.stopPropagation();
635
+ onEventClick?.(ev);
636
+ }}
637
+ class="w-full truncate rounded px-2 py-0.5 text-left text-[11px] font-semibold text-white shadow-2xs hover:opacity-90"
638
+ style="background-color: {ev.color || 'var(--ui-primary)'};"
639
+ >
640
+ {ev.title} {#if ev.description}• {ev.description}{/if}
641
+ </button>
642
+ {/each}
643
+ </div>
644
+ </div>
645
+ {/if}
646
+
380
647
  <div class="grid grid-cols-8 divide-x divide-[var(--ui-border)] max-h-[600px] overflow-y-auto">
381
648
  <!-- Hours Column -->
382
649
  <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)]">
@@ -387,7 +654,8 @@
387
654
 
388
655
  <!-- 7 Days Grid Columns -->
389
656
  {#each weekDays as d}
390
- {@const dayEvents = getEventsForDay(d)}
657
+ {@const dayEvents = getTimedEventsForDay(d)}
658
+ {@const layoutEvents = computeOverlappingLayout(dayEvents)}
391
659
  {@const isCurrentDay = isToday(d)}
392
660
  {@const nowTime = getTimeInZone(new Date())}
393
661
  {@const nowMinutes = nowTime.hours * 60 + nowTime.minutes}
@@ -395,14 +663,15 @@
395
663
 
396
664
  <!-- svelte-ignore a11y_no_static_element_interactions -->
397
665
  <!-- svelte-ignore a11y_click_events_have_key_events -->
398
- <div
666
+ <div id="week-day-col"
399
667
  onclick={() => openCreateModal(d)}
400
- class="col-span-1 divide-y divide-[var(--ui-border)]/50 relative hover:bg-[var(--ui-secondary)]/10 cursor-pointer"
668
+ class="col-span-1 divide-y divide-[var(--ui-border)]/50 relative hover:bg-[var(--ui-secondary)]/10 cursor-pointer select-none"
401
669
  >
402
670
  {#each hours as hour}
403
671
  <!-- svelte-ignore a11y_no_static_element_interactions -->
404
672
  <div
405
- class="h-14 transition-colors hover:bg-[var(--ui-primary)]/10"
673
+ class="h-14 transition-colors hover:bg-[var(--ui-primary)]/10 pointer-events-auto"
674
+ onmousedown={(e) => handleDragCreateMouseDown(e, d, hour, e.currentTarget.closest('[id="week-day-col"]')!)}
406
675
  ondragover={(e) => e.preventDefault()}
407
676
  ondrop={(e) => {
408
677
  e.preventDefault();
@@ -423,12 +692,22 @@
423
692
  </div>
424
693
  {/if}
425
694
 
426
- <!-- Render Event Boxes -->
427
- {#each dayEvents as ev (ev.id)}
428
- {@const evTime = getTimeInZone(new Date(ev.start))}
429
- {@const topPos = (evTime.hours + evTime.minutes / 60) * 56}
430
- <button
431
- type="button"
695
+ <!-- Render Event Boxes with Collision Overlap Calculation & Resizing -->
696
+ {#each layoutEvents as ev (ev.id)}
697
+ {@const evStartTime = getTimeInZone(new Date(ev.start))}
698
+ {@const evEndTime = getTimeInZone(new Date(ev.end))}
699
+ {@const startMinutes = evStartTime.hours * 60 + evStartTime.minutes}
700
+ {@const endMinutes = evEndTime.hours * 60 + evEndTime.minutes}
701
+ {@const durationMinutes = Math.max(15, endMinutes > startMinutes ? endMinutes - startMinutes : 60)}
702
+ {@const topPos = (startMinutes / 60) * 56}
703
+ {@const heightPos = Math.max(26, (durationMinutes / 60) * 56)}
704
+ {@const widthPercent = 100 / ev.colTotal}
705
+ {@const leftPercent = ev.colIndex * widthPercent}
706
+
707
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
708
+ <div
709
+ role="button"
710
+ tabindex="0"
432
711
  draggable="true"
433
712
  ondragstart={(e) => {
434
713
  draggedEventId = ev.id;
@@ -438,13 +717,41 @@
438
717
  e.stopPropagation();
439
718
  onEventClick?.(ev);
440
719
  }}
441
- 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 cursor-grab active:cursor-grabbing {draggedEventId === ev.id ? 'opacity-40 ring-2 ring-white' : ''}"
442
- style="top: {topPos}px; height: 50px; background-color: {ev.color || 'var(--ui-primary)'};"
720
+ onkeydown={(e) => {
721
+ if (e.key === 'Enter' || e.key === ' ') {
722
+ e.preventDefault();
723
+ onEventClick?.(ev);
724
+ }
725
+ }}
726
+ class="group/event absolute rounded p-1 text-left text-[11px] text-white shadow-xs transition-opacity hover:opacity-95 overflow-hidden z-10 cursor-grab active:cursor-grabbing {draggedEventId === ev.id ? 'opacity-40 ring-2 ring-white' : ''}"
727
+ style="top: {topPos}px; height: {heightPos}px; left: calc({leftPercent}% + 2px); width: calc({widthPercent}% - 4px); background-color: {ev.color || 'var(--ui-primary)'};"
443
728
  >
444
- <div class="font-bold truncate">{ev.title}</div>
445
- <div class="text-[9px] opacity-80">{formatTime(ev.start)} - {formatTime(ev.end)}</div>
446
- </button>
729
+ <div class="font-bold truncate leading-tight">{ev.title}</div>
730
+ <div class="text-[9px] opacity-85 leading-tight">{formatTime(ev.start)} - {formatTime(ev.end)}</div>
731
+
732
+ <!-- Bottom edge resize handle (FullCalendar 1:1) -->
733
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
734
+ <div
735
+ class="absolute inset-x-0 bottom-0 h-2 cursor-s-resize opacity-0 group-hover/event:opacity-100 bg-white/20 hover:bg-white/40 transition-opacity z-20"
736
+ onmousedown={(e) => handleResizeStart(e, ev)}
737
+ ></div>
738
+ </div>
447
739
  {/each}
740
+ <!-- Drag-to-create preview -->
741
+ {#if isDragCreating && dragCreateStart && dragCreateEnd && dragCreateColumn && isSameDay(dragCreateColumn, d)}
742
+ {@const startTotalMin = dragCreateStart.hour * 60 + dragCreateStart.minute}
743
+ {@const endTotalMin = dragCreateEnd.hour * 60 + dragCreateEnd.minute}
744
+ {@const previewTop = (startTotalMin / 60) * 56}
745
+ {@const previewHeight = Math.max(14, ((endTotalMin - startTotalMin) / 60) * 56)}
746
+ <div
747
+ 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"
748
+ style="top: {previewTop}px; height: {previewHeight}px;"
749
+ >
750
+ <span class="text-[9px] font-semibold text-blue-700 select-none">
751
+ {String(dragCreateStart.hour).padStart(2, '0')}:{String(dragCreateStart.minute).padStart(2, '0')} - {String(dragCreateEnd.hour).padStart(2, '0')}:{String(dragCreateEnd.minute).padStart(2, '0')}
752
+ </span>
753
+ </div>
754
+ {/if}
448
755
  </div>
449
756
  {/each}
450
757
  </div>
@@ -469,12 +776,14 @@
469
776
 
470
777
  <!-- svelte-ignore a11y_no_static_element_interactions -->
471
778
  <!-- svelte-ignore a11y_click_events_have_key_events -->
472
- <div
779
+ <div id="day-view-col"
473
780
  onclick={() => openCreateModal(currentDate)}
474
- class="col-span-10 divide-y divide-[var(--ui-border)]/50 relative p-1 hover:bg-[var(--ui-secondary)]/10 cursor-pointer"
781
+ class="col-span-10 divide-y divide-[var(--ui-border)]/50 relative p-1 hover:bg-[var(--ui-secondary)]/10 cursor-pointer select-none"
475
782
  >
476
- {#each hours as _}
477
- <div class="h-16"></div>
783
+ {#each hours as hour}
784
+ <div class="h-16 pointer-events-auto"
785
+ onmousedown={(e) => handleDragCreateMouseDown(e, currentDate, hour, e.currentTarget.closest('[id="day-view-col"]')!)}
786
+ ></div>
478
787
  {/each}
479
788
 
480
789
  <!-- Google Calendar style Current Time Red Line -->
@@ -490,22 +799,65 @@
490
799
  </div>
491
800
  {/if}
492
801
 
493
- {#each getEventsForDay(currentDate) as ev (ev.id)}
494
- {@const evTime = getTimeInZone(new Date(ev.start))}
495
- {@const topPos = (evTime.hours + evTime.minutes / 60) * 64}
496
- <button
497
- type="button"
802
+ {#each computeOverlappingLayout(getTimedEventsForDay(currentDate)) as ev (ev.id)}
803
+ {@const evStartTime = getTimeInZone(new Date(ev.start))}
804
+ {@const evEndTime = getTimeInZone(new Date(ev.end))}
805
+ {@const startMinutes = evStartTime.hours * 60 + evStartTime.minutes}
806
+ {@const endMinutes = evEndTime.hours * 60 + evEndTime.minutes}
807
+ {@const durationMinutes = Math.max(15, endMinutes > startMinutes ? endMinutes - startMinutes : 60)}
808
+ {@const topPos = (startMinutes / 60) * 64}
809
+ {@const heightPos = Math.max(32, (durationMinutes / 60) * 64)}
810
+ {@const widthPercent = 100 / ev.colTotal}
811
+ {@const leftPercent = ev.colIndex * widthPercent}
812
+
813
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
814
+ <div
815
+ role="button"
816
+ tabindex="0"
817
+ draggable="true"
818
+ ondragstart={(e) => {
819
+ draggedEventId = ev.id;
820
+ e.dataTransfer?.setData('text/plain', ev.id);
821
+ }}
498
822
  onclick={(e) => {
499
823
  e.stopPropagation();
500
824
  onEventClick?.(ev);
501
825
  }}
502
- 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"
503
- style="top: {topPos}px; height: 56px; background-color: {ev.color || 'var(--ui-primary)'};"
826
+ onkeydown={(e) => {
827
+ if (e.key === 'Enter' || e.key === ' ') {
828
+ e.preventDefault();
829
+ onEventClick?.(ev);
830
+ }
831
+ }}
832
+ class="group/event absolute rounded-lg p-2 text-left text-white shadow-sm transition-opacity hover:opacity-95 overflow-hidden z-10 cursor-grab active:cursor-grabbing {draggedEventId === ev.id ? 'opacity-40 ring-2 ring-white' : ''}"
833
+ style="top: {topPos}px; height: {heightPos}px; left: calc({leftPercent}% + 4px); width: calc({widthPercent}% - 8px); background-color: {ev.color || 'var(--ui-primary)'};"
504
834
  >
505
- <div class="font-bold text-xs">{ev.title}</div>
506
- <div class="text-[10px] opacity-80">{formatTime(ev.start)} - {formatTime(ev.end)} {#if ev.description}• {ev.description}{/if}</div>
507
- </button>
835
+ <div class="font-bold text-xs truncate leading-tight">{ev.title}</div>
836
+ <div class="text-[10px] opacity-85 leading-tight">{formatTime(ev.start)} - {formatTime(ev.end)} {#if ev.description}• {ev.description}{/if}</div>
837
+
838
+ <!-- Bottom resize handle -->
839
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
840
+ <div
841
+ class="absolute inset-x-0 bottom-0 h-2.5 cursor-s-resize opacity-0 group-hover/event:opacity-100 bg-white/20 hover:bg-white/40 transition-opacity z-20"
842
+ onmousedown={(e) => handleResizeStart(e, ev)}
843
+ ></div>
844
+ </div>
508
845
  {/each}
846
+ <!-- Drag-to-create preview -->
847
+ {#if isDragCreating && dragCreateStart && dragCreateEnd && isSameDay(dragCreateColumn!, currentDate)}
848
+ {@const startTotalMin = dragCreateStart.hour * 60 + dragCreateStart.minute}
849
+ {@const endTotalMin = dragCreateEnd.hour * 60 + dragCreateEnd.minute}
850
+ {@const previewTop = (startTotalMin / 60) * 64}
851
+ {@const previewHeight = Math.max(16, ((endTotalMin - startTotalMin) / 60) * 64)}
852
+ <div
853
+ 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"
854
+ style="top: {previewTop}px; height: {previewHeight}px;"
855
+ >
856
+ <span class="text-[10px] font-semibold text-blue-700 select-none">
857
+ {String(dragCreateStart.hour).padStart(2, '0')}:{String(dragCreateStart.minute).padStart(2, '0')} - {String(dragCreateEnd.hour).padStart(2, '0')}:{String(dragCreateEnd.minute).padStart(2, '0')}
858
+ </span>
859
+ </div>
860
+ {/if}
509
861
  </div>
510
862
  </div>
511
863
  {/if}
@@ -25,6 +25,15 @@ type $$ComponentProps = {
25
25
  newStart: Date;
26
26
  newEnd: Date;
27
27
  }) => void;
28
+ onEventResize?: (detail: {
29
+ event: CalendarEvent;
30
+ newStart: Date;
31
+ newEnd: Date;
32
+ }) => void;
33
+ onDragCreate?: (detail: {
34
+ start: Date;
35
+ end: Date;
36
+ }) => void;
28
37
  };
29
38
  declare const EventCalendar: import("svelte").Component<$$ComponentProps, {}, "events" | "view" | "currentDate">;
30
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.12",
3
+ "version": "0.1.1-alpha.14",
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.12",
23
24
  "date-fns": "^4.4.0",
24
25
  "lucide-svelte": "^1.0.1"
25
26
  },