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

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.
@@ -58,18 +58,33 @@
58
58
  onDateClick?: (date: Date) => void;
59
59
  onAddEvent?: (newEvent: CalendarEvent) => void;
60
60
  onEventReschedule?: (detail: { event: CalendarEvent; newStart: Date; newEnd: Date }) => void;
61
+ onEventResize?: (detail: { event: CalendarEvent; newStart: Date; newEnd: Date }) => void;
61
62
  } = $props();
62
63
 
63
64
  let draggedEventId = $state<string | null>(null);
65
+ let resizingEventId = $state<string | null>(null);
66
+ let resizeStartY = $state<number>(0);
67
+ let resizeStartDuration = $state<number>(0);
68
+
69
+ function isMultiDayOrAllDay(ev: CalendarEvent): boolean {
70
+ if (ev.allDay) return true;
71
+ const s = new Date(ev.start);
72
+ const e = new Date(ev.end);
73
+ return !isSameDay(s, e) || (e.getTime() - s.getTime() >= 24 * 60 * 60 * 1000);
74
+ }
64
75
 
65
- function handleEventDrop(targetDate: Date, targetHour: number) {
76
+ function handleEventDrop(targetDate: Date, targetHour?: number) {
66
77
  if (!draggedEventId) return;
67
78
  const ev = events.find((e) => e.id === draggedEventId);
68
79
  if (!ev) return;
69
80
 
70
81
  const origDurationMs = new Date(ev.end).getTime() - new Date(ev.start).getTime();
71
82
  const newStart = new Date(targetDate);
72
- newStart.setHours(targetHour, 0, 0, 0);
83
+ if (targetHour !== undefined) {
84
+ newStart.setHours(targetHour, 0, 0, 0);
85
+ } else {
86
+ newStart.setHours(new Date(ev.start).getHours(), new Date(ev.start).getMinutes(), 0, 0);
87
+ }
73
88
  const newEnd = new Date(newStart.getTime() + origDurationMs);
74
89
 
75
90
  events = events.map((e) =>
@@ -79,15 +94,115 @@
79
94
  onEventReschedule?.({ event: ev, newStart, newEnd });
80
95
  draggedEventId = null;
81
96
  }
82
- const activeDateFnsLocale = $derived(locale.startsWith('id') ? localeId : localeEn);
83
- const weekStartsOn = $derived(firstDayOfWeek as 0 | 1 | 2 | 3 | 4 | 5 | 6);
97
+
98
+ function handleResizeStart(e: MouseEvent, ev: CalendarEvent) {
99
+ e.stopPropagation();
100
+ e.preventDefault();
101
+ resizingEventId = ev.id;
102
+ resizeStartY = e.clientY;
103
+ resizeStartDuration = new Date(ev.end).getTime() - new Date(ev.start).getTime();
104
+
105
+ function onMouseMove(moveEvent: MouseEvent) {
106
+ if (!resizingEventId) return;
107
+ const deltaY = moveEvent.clientY - resizeStartY;
108
+ // 56px per hour => deltaY / 56 hours
109
+ const deltaHours = deltaY / 56;
110
+ // Snap to 15 minute increments (0.25h = 15 * 60 * 1000 ms)
111
+ const snapIntervalMs = 15 * 60 * 1000;
112
+ const rawDeltaMs = deltaHours * 60 * 60 * 1000;
113
+ const snappedDeltaMs = Math.round(rawDeltaMs / snapIntervalMs) * snapIntervalMs;
114
+ const newDurationMs = Math.max(15 * 60 * 1000, resizeStartDuration + snappedDeltaMs);
115
+
116
+ events = events.map((item) => {
117
+ if (item.id === resizingEventId) {
118
+ const s = new Date(item.start);
119
+ return { ...item, end: new Date(s.getTime() + newDurationMs) };
120
+ }
121
+ return item;
122
+ });
123
+ }
124
+
125
+ function onMouseUp() {
126
+ if (resizingEventId) {
127
+ const finished = events.find((item) => item.id === resizingEventId);
128
+ if (finished) {
129
+ onEventResize?.({ event: finished, newStart: new Date(finished.start), newEnd: new Date(finished.end) });
130
+ }
131
+ }
132
+ resizingEventId = null;
133
+ window.removeEventListener('mousemove', onMouseMove);
134
+ window.removeEventListener('mouseup', onMouseUp);
135
+ }
136
+
137
+ window.addEventListener('mousemove', onMouseMove);
138
+ window.addEventListener('mouseup', onMouseUp);
139
+ }
140
+
141
+ type LayoutEvent = CalendarEvent & {
142
+ colIndex: number;
143
+ colTotal: number;
144
+ };
145
+
146
+ function computeOverlappingLayout(dayEvs: CalendarEvent[]): LayoutEvent[] {
147
+ // Sort events by start time, then longer events first
148
+ const sorted = [...dayEvs].sort((a, b) => {
149
+ const diff = new Date(a.start).getTime() - new Date(b.start).getTime();
150
+ if (diff !== 0) return diff;
151
+ return (new Date(b.end).getTime() - new Date(b.start).getTime()) - (new Date(a.end).getTime() - new Date(a.start).getTime());
152
+ });
153
+
154
+ const columns: CalendarEvent[][] = [];
155
+ const layoutMap = new Map<string, { colIndex: number }>();
156
+
157
+ for (const ev of sorted) {
158
+ let placed = false;
159
+ for (let i = 0; i < columns.length; i++) {
160
+ const lastInCol = columns[i][columns[i].length - 1];
161
+ if (new Date(lastInCol.end).getTime() <= new Date(ev.start).getTime()) {
162
+ columns[i].push(ev);
163
+ layoutMap.set(ev.id, { colIndex: i });
164
+ placed = true;
165
+ break;
166
+ }
167
+ }
168
+ if (!placed) {
169
+ columns.push([ev]);
170
+ layoutMap.set(ev.id, { colIndex: columns.length - 1 });
171
+ }
172
+ }
173
+
174
+ // Determine overlapping groups
175
+ return sorted.map((ev) => {
176
+ const colInfo = layoutMap.get(ev.id) || { colIndex: 0 };
177
+ // Count how many columns overlap with this event
178
+ let overlappingCols = 1;
179
+ for (let c = 0; c < columns.length; c++) {
180
+ const hasOverlap = columns[c].some(
181
+ (other) =>
182
+ new Date(other.start).getTime() < new Date(ev.end).getTime() &&
183
+ new Date(other.end).getTime() > new Date(ev.start).getTime()
184
+ );
185
+ if (hasOverlap && c + 1 > overlappingCols) {
186
+ overlappingCols = c + 1;
187
+ }
188
+ }
189
+
190
+ return {
191
+ ...ev,
192
+ colIndex: colInfo.colIndex,
193
+ colTotal: Math.max(1, overlappingCols),
194
+ };
195
+ });
196
+ }
84
197
 
85
198
  const activeTimeZone = $derived(
86
199
  timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
87
200
  );
88
201
 
89
- const hours = Array.from({ length: 24 }, (_, i) => i);
202
+ const activeDateFnsLocale = $derived(locale.startsWith('id') ? localeId : localeEn);
203
+ const weekStartsOn = $derived(firstDayOfWeek as 0 | 1 | 2 | 3 | 4 | 5 | 6);
90
204
 
205
+ const hours = Array.from({ length: 24 }, (_, i) => i);
91
206
  // Day names localized and rotated according to firstDayOfWeek via date-fns
92
207
  const dayNames = $derived.by(() => {
93
208
  const start = startOfWeek(currentDate, { weekStartsOn });
@@ -137,7 +252,26 @@
137
252
  });
138
253
 
139
254
  function getEventsForDay(d: Date): CalendarEvent[] {
140
- return events.filter((ev) => isSameDay(new Date(ev.start), d));
255
+ return events.filter((ev) => {
256
+ if (isSameDay(new Date(ev.start), d)) return true;
257
+ // Multi-day spanning: check if date falls between start and end
258
+ const s = new Date(ev.start);
259
+ const e = new Date(ev.end);
260
+ return d >= s && d <= e;
261
+ });
262
+ }
263
+
264
+ function getTimedEventsForDay(d: Date): CalendarEvent[] {
265
+ return events.filter((ev) => !isMultiDayOrAllDay(ev) && isSameDay(new Date(ev.start), d));
266
+ }
267
+
268
+ function getAllDayEventsForWeek(days: Date[]): CalendarEvent[] {
269
+ return events.filter((ev) => {
270
+ if (!isMultiDayOrAllDay(ev)) return false;
271
+ const s = new Date(ev.start);
272
+ const e = new Date(ev.end);
273
+ return days.some((d) => isSameDay(d, s) || isSameDay(d, e) || (d > s && d < e));
274
+ });
141
275
  }
142
276
 
143
277
  function getTimeInZone(date: Date): { hours: number; minutes: number } {
@@ -314,12 +448,18 @@
314
448
  onDateClick?.(date);
315
449
  openCreateModal(date);
316
450
  }}
451
+ ondragover={(e) => e.preventDefault()}
452
+ ondrop={(e) => {
453
+ e.preventDefault();
454
+ e.stopPropagation();
455
+ handleEventDrop(date);
456
+ }}
317
457
  class={cn(
318
- 'min-h-[100px] p-2 flex flex-col gap-1 transition-colors hover:bg-[var(--ui-secondary)]/25 cursor-pointer',
458
+ 'min-h-[110px] p-1.5 flex flex-col gap-1 transition-colors hover:bg-[var(--ui-secondary)]/25 cursor-pointer select-none',
319
459
  !currentMonth && 'bg-[var(--ui-muted)]/15 opacity-50'
320
460
  )}
321
461
  >
322
- <div class="flex items-center justify-between mb-1">
462
+ <div class="flex items-center justify-between mb-0.5 px-1">
323
463
  <span
324
464
  class={cn(
325
465
  'text-xs font-semibold size-6 flex items-center justify-center rounded-full',
@@ -335,27 +475,44 @@
335
475
  {/if}
336
476
  </div>
337
477
 
338
- <!-- Event pills -->
478
+ <!-- Event pills with spanning indicator -->
339
479
  <div class="flex flex-col gap-1 overflow-hidden">
340
480
  {#each dayEvents.slice(0, 3) as ev (ev.id)}
481
+ {@const isSpanning = isMultiDayOrAllDay(ev)}
482
+ {@const isStartDay = isSameDay(new Date(ev.start), date)}
483
+ {@const isEndDay = isSameDay(new Date(ev.end), date)}
484
+
341
485
  <button
342
486
  type="button"
487
+ draggable="true"
488
+ ondragstart={(e) => {
489
+ e.stopPropagation();
490
+ draggedEventId = ev.id;
491
+ e.dataTransfer?.setData('text/plain', ev.id);
492
+ }}
343
493
  onclick={(e) => {
344
494
  e.stopPropagation();
345
495
  onEventClick?.(ev);
346
496
  }}
347
497
  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)]'
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',
499
+ isSpanning ? 'rounded-none' : 'rounded',
500
+ isSpanning && isStartDay && 'rounded-l-md',
501
+ isSpanning && isEndDay && 'rounded-r-md',
502
+ draggedEventId === ev.id ? 'opacity-40 ring-2 ring-white' : ''
350
503
  )}
351
- style={ev.color ? `background-color: ${ev.color};` : undefined}
504
+ style="background-color: {ev.color || 'var(--ui-primary)'};"
352
505
  >
353
- {formatTime(ev.start)} {ev.title}
506
+ {#if isSpanning}
507
+ <span class="font-semibold">{ev.title}</span>
508
+ {:else}
509
+ <span class="opacity-80 font-normal">{formatTime(ev.start)}</span> {ev.title}
510
+ {/if}
354
511
  </button>
355
512
  {/each}
356
513
 
357
514
  {#if dayEvents.length > 3}
358
- <span class="text-[10px] font-semibold text-[var(--ui-muted-foreground)] pl-1">
515
+ <span class="text-[10px] font-semibold text-[var(--ui-muted-foreground)] px-1">
359
516
  +{dayEvents.length - 3} more
360
517
  </span>
361
518
  {/if}
@@ -377,6 +534,31 @@
377
534
  {/each}
378
535
  </div>
379
536
 
537
+ <!-- All-day header slot (FullCalendar 1:1) -->
538
+ {@const allDayEvents = getAllDayEventsForWeek(weekDays)}
539
+ {#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">
541
+ <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
+ all-day
543
+ </div>
544
+ <div class="col-span-7 px-2 flex flex-col gap-1">
545
+ {#each allDayEvents as ev (ev.id)}
546
+ <button
547
+ type="button"
548
+ onclick={(e) => {
549
+ e.stopPropagation();
550
+ onEventClick?.(ev);
551
+ }}
552
+ class="w-full truncate rounded px-2 py-0.5 text-left text-[11px] font-semibold text-white shadow-2xs hover:opacity-90"
553
+ style="background-color: {ev.color || 'var(--ui-primary)'};"
554
+ >
555
+ {ev.title} {#if ev.description}• {ev.description}{/if}
556
+ </button>
557
+ {/each}
558
+ </div>
559
+ </div>
560
+ {/if}
561
+
380
562
  <div class="grid grid-cols-8 divide-x divide-[var(--ui-border)] max-h-[600px] overflow-y-auto">
381
563
  <!-- Hours Column -->
382
564
  <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 +569,8 @@
387
569
 
388
570
  <!-- 7 Days Grid Columns -->
389
571
  {#each weekDays as d}
390
- {@const dayEvents = getEventsForDay(d)}
572
+ {@const dayEvents = getTimedEventsForDay(d)}
573
+ {@const layoutEvents = computeOverlappingLayout(dayEvents)}
391
574
  {@const isCurrentDay = isToday(d)}
392
575
  {@const nowTime = getTimeInZone(new Date())}
393
576
  {@const nowMinutes = nowTime.hours * 60 + nowTime.minutes}
@@ -397,7 +580,7 @@
397
580
  <!-- svelte-ignore a11y_click_events_have_key_events -->
398
581
  <div
399
582
  onclick={() => openCreateModal(d)}
400
- class="col-span-1 divide-y divide-[var(--ui-border)]/50 relative hover:bg-[var(--ui-secondary)]/10 cursor-pointer"
583
+ class="col-span-1 divide-y divide-[var(--ui-border)]/50 relative hover:bg-[var(--ui-secondary)]/10 cursor-pointer select-none"
401
584
  >
402
585
  {#each hours as hour}
403
586
  <!-- svelte-ignore a11y_no_static_element_interactions -->
@@ -423,12 +606,22 @@
423
606
  </div>
424
607
  {/if}
425
608
 
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"
609
+ <!-- Render Event Boxes with Collision Overlap Calculation & Resizing -->
610
+ {#each layoutEvents as ev (ev.id)}
611
+ {@const evStartTime = getTimeInZone(new Date(ev.start))}
612
+ {@const evEndTime = getTimeInZone(new Date(ev.end))}
613
+ {@const startMinutes = evStartTime.hours * 60 + evStartTime.minutes}
614
+ {@const endMinutes = evEndTime.hours * 60 + evEndTime.minutes}
615
+ {@const durationMinutes = Math.max(15, endMinutes > startMinutes ? endMinutes - startMinutes : 60)}
616
+ {@const topPos = (startMinutes / 60) * 56}
617
+ {@const heightPos = Math.max(26, (durationMinutes / 60) * 56)}
618
+ {@const widthPercent = 100 / ev.colTotal}
619
+ {@const leftPercent = ev.colIndex * widthPercent}
620
+
621
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
622
+ <div
623
+ role="button"
624
+ tabindex="0"
432
625
  draggable="true"
433
626
  ondragstart={(e) => {
434
627
  draggedEventId = ev.id;
@@ -438,12 +631,25 @@
438
631
  e.stopPropagation();
439
632
  onEventClick?.(ev);
440
633
  }}
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)'};"
634
+ onkeydown={(e) => {
635
+ if (e.key === 'Enter' || e.key === ' ') {
636
+ e.preventDefault();
637
+ onEventClick?.(ev);
638
+ }
639
+ }}
640
+ 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' : ''}"
641
+ style="top: {topPos}px; height: {heightPos}px; left: calc({leftPercent}% + 2px); width: calc({widthPercent}% - 4px); background-color: {ev.color || 'var(--ui-primary)'};"
443
642
  >
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>
643
+ <div class="font-bold truncate leading-tight">{ev.title}</div>
644
+ <div class="text-[9px] opacity-85 leading-tight">{formatTime(ev.start)} - {formatTime(ev.end)}</div>
645
+
646
+ <!-- Bottom edge resize handle (FullCalendar 1:1) -->
647
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
648
+ <div
649
+ 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"
650
+ onmousedown={(e) => handleResizeStart(e, ev)}
651
+ ></div>
652
+ </div>
447
653
  {/each}
448
654
  </div>
449
655
  {/each}
@@ -471,7 +677,7 @@
471
677
  <!-- svelte-ignore a11y_click_events_have_key_events -->
472
678
  <div
473
679
  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"
680
+ 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
681
  >
476
682
  {#each hours as _}
477
683
  <div class="h-16"></div>
@@ -490,21 +696,49 @@
490
696
  </div>
491
697
  {/if}
492
698
 
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"
699
+ {#each computeOverlappingLayout(getTimedEventsForDay(currentDate)) as ev (ev.id)}
700
+ {@const evStartTime = getTimeInZone(new Date(ev.start))}
701
+ {@const evEndTime = getTimeInZone(new Date(ev.end))}
702
+ {@const startMinutes = evStartTime.hours * 60 + evStartTime.minutes}
703
+ {@const endMinutes = evEndTime.hours * 60 + evEndTime.minutes}
704
+ {@const durationMinutes = Math.max(15, endMinutes > startMinutes ? endMinutes - startMinutes : 60)}
705
+ {@const topPos = (startMinutes / 60) * 64}
706
+ {@const heightPos = Math.max(32, (durationMinutes / 60) * 64)}
707
+ {@const widthPercent = 100 / ev.colTotal}
708
+ {@const leftPercent = ev.colIndex * widthPercent}
709
+
710
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
711
+ <div
712
+ role="button"
713
+ tabindex="0"
714
+ draggable="true"
715
+ ondragstart={(e) => {
716
+ draggedEventId = ev.id;
717
+ e.dataTransfer?.setData('text/plain', ev.id);
718
+ }}
498
719
  onclick={(e) => {
499
720
  e.stopPropagation();
500
721
  onEventClick?.(ev);
501
722
  }}
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)'};"
723
+ onkeydown={(e) => {
724
+ if (e.key === 'Enter' || e.key === ' ') {
725
+ e.preventDefault();
726
+ onEventClick?.(ev);
727
+ }
728
+ }}
729
+ 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' : ''}"
730
+ style="top: {topPos}px; height: {heightPos}px; left: calc({leftPercent}% + 4px); width: calc({widthPercent}% - 8px); background-color: {ev.color || 'var(--ui-primary)'};"
504
731
  >
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>
732
+ <div class="font-bold text-xs truncate leading-tight">{ev.title}</div>
733
+ <div class="text-[10px] opacity-85 leading-tight">{formatTime(ev.start)} - {formatTime(ev.end)} {#if ev.description}• {ev.description}{/if}</div>
734
+
735
+ <!-- Bottom resize handle -->
736
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
737
+ <div
738
+ 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"
739
+ onmousedown={(e) => handleResizeStart(e, ev)}
740
+ ></div>
741
+ </div>
508
742
  {/each}
509
743
  </div>
510
744
  </div>
@@ -25,6 +25,11 @@ 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;
28
33
  };
29
34
  declare const EventCalendar: import("svelte").Component<$$ComponentProps, {}, "events" | "view" | "currentDate">;
30
35
  type EventCalendar = ReturnType<typeof EventCalendar>;
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.13",
4
4
  "description": "Calendar and DatePicker components for intinyagroup UI",
5
5
  "type": "module",
6
6
  "exports": {