@intinyagroup/calendar 0.1.1-alpha.11 → 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.
@@ -1,9 +1,9 @@
1
1
  <script lang="ts">
2
2
  import * as Popover from '@intinyagroup/ui';
3
3
  import { Button, Input } from '@intinyagroup/ui';
4
- import { Calendar, type CalendarDate } from '@intinyagroup/calendar';
4
+ import Calendar, { type CalendarDate } from './Calendar.svelte';
5
5
  import { Calendar as CalendarIcon, X } from 'lucide-svelte';
6
- import { cn } from '@intinyagroup/grid-core/utils';
6
+ import { cn } from '@intinyagroup/ui/utils';
7
7
 
8
8
  let {
9
9
  value = $bindable(null),
@@ -1,4 +1,4 @@
1
- import { type CalendarDate } from '@intinyagroup/calendar';
1
+ import { type CalendarDate } from './Calendar.svelte';
2
2
  type $$ComponentProps = {
3
3
  value?: CalendarDate | null;
4
4
  placeholder?: string;
@@ -186,14 +186,23 @@
186
186
  </script>
187
187
 
188
188
  <div class={cn('relative inline-block w-full max-w-sm', className)}>
189
- <button
190
- type="button"
191
- {disabled}
192
- onclick={() => (isOpen = !isOpen)}
189
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
190
+ <div
191
+ role="button"
192
+ tabindex={disabled ? -1 : 0}
193
+ aria-disabled={disabled}
194
+ onclick={() => { if (!disabled) isOpen = !isOpen; }}
195
+ onkeydown={(e) => {
196
+ if (disabled) return;
197
+ if (e.key === 'Enter' || e.key === ' ') {
198
+ e.preventDefault();
199
+ isOpen = !isOpen;
200
+ }
201
+ }}
193
202
  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',
203
+ '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 cursor-pointer select-none',
195
204
  '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',
205
+ disabled && 'opacity-50 cursor-not-allowed pointer-events-none',
197
206
  value.start ? 'text-[var(--ui-foreground)]' : 'text-[var(--ui-muted-foreground)]'
198
207
  )}
199
208
  >
@@ -205,14 +214,17 @@
205
214
  {#if value.start && !disabled}
206
215
  <button
207
216
  type="button"
208
- onclick={clearRange}
217
+ onclick={(e) => {
218
+ e.stopPropagation();
219
+ clearRange();
220
+ }}
209
221
  class="p-1 rounded-full hover:bg-[var(--ui-secondary)] text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)]"
210
222
  aria-label="Clear date range"
211
223
  >
212
224
  <X class="size-3.5" />
213
225
  </button>
214
226
  {/if}
215
- </button>
227
+ </div>
216
228
 
217
229
  {#if isOpen}
218
230
  <!-- Backdrop for click outside -->
@@ -44,6 +44,7 @@
44
44
  onEventClick,
45
45
  onDateClick,
46
46
  onAddEvent,
47
+ onEventReschedule,
47
48
  }: {
48
49
  events?: CalendarEvent[];
49
50
  view?: CalendarView;
@@ -56,17 +57,152 @@
56
57
  onEventClick?: (event: CalendarEvent) => void;
57
58
  onDateClick?: (date: Date) => void;
58
59
  onAddEvent?: (newEvent: CalendarEvent) => void;
60
+ onEventReschedule?: (detail: { event: CalendarEvent; newStart: Date; newEnd: Date }) => void;
61
+ onEventResize?: (detail: { event: CalendarEvent; newStart: Date; newEnd: Date }) => void;
59
62
  } = $props();
60
63
 
61
- const activeDateFnsLocale = $derived(locale.startsWith('id') ? localeId : localeEn);
62
- const weekStartsOn = $derived(firstDayOfWeek as 0 | 1 | 2 | 3 | 4 | 5 | 6);
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
+ }
75
+
76
+ function handleEventDrop(targetDate: Date, targetHour?: number) {
77
+ if (!draggedEventId) return;
78
+ const ev = events.find((e) => e.id === draggedEventId);
79
+ if (!ev) return;
80
+
81
+ const origDurationMs = new Date(ev.end).getTime() - new Date(ev.start).getTime();
82
+ const newStart = new Date(targetDate);
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
+ }
88
+ const newEnd = new Date(newStart.getTime() + origDurationMs);
89
+
90
+ events = events.map((e) =>
91
+ e.id === draggedEventId ? { ...e, start: newStart, end: newEnd } : e
92
+ );
93
+
94
+ onEventReschedule?.({ event: ev, newStart, newEnd });
95
+ draggedEventId = null;
96
+ }
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
+ }
63
197
 
64
198
  const activeTimeZone = $derived(
65
199
  timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
66
200
  );
67
201
 
68
- 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);
69
204
 
205
+ const hours = Array.from({ length: 24 }, (_, i) => i);
70
206
  // Day names localized and rotated according to firstDayOfWeek via date-fns
71
207
  const dayNames = $derived.by(() => {
72
208
  const start = startOfWeek(currentDate, { weekStartsOn });
@@ -116,7 +252,26 @@
116
252
  });
117
253
 
118
254
  function getEventsForDay(d: Date): CalendarEvent[] {
119
- 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
+ });
120
275
  }
121
276
 
122
277
  function getTimeInZone(date: Date): { hours: number; minutes: number } {
@@ -293,12 +448,18 @@
293
448
  onDateClick?.(date);
294
449
  openCreateModal(date);
295
450
  }}
451
+ ondragover={(e) => e.preventDefault()}
452
+ ondrop={(e) => {
453
+ e.preventDefault();
454
+ e.stopPropagation();
455
+ handleEventDrop(date);
456
+ }}
296
457
  class={cn(
297
- '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',
298
459
  !currentMonth && 'bg-[var(--ui-muted)]/15 opacity-50'
299
460
  )}
300
461
  >
301
- <div class="flex items-center justify-between mb-1">
462
+ <div class="flex items-center justify-between mb-0.5 px-1">
302
463
  <span
303
464
  class={cn(
304
465
  'text-xs font-semibold size-6 flex items-center justify-center rounded-full',
@@ -314,27 +475,44 @@
314
475
  {/if}
315
476
  </div>
316
477
 
317
- <!-- Event pills -->
478
+ <!-- Event pills with spanning indicator -->
318
479
  <div class="flex flex-col gap-1 overflow-hidden">
319
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
+
320
485
  <button
321
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
+ }}
322
493
  onclick={(e) => {
323
494
  e.stopPropagation();
324
495
  onEventClick?.(ev);
325
496
  }}
326
497
  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)]'
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' : ''
329
503
  )}
330
- style={ev.color ? `background-color: ${ev.color};` : undefined}
504
+ style="background-color: {ev.color || 'var(--ui-primary)'};"
331
505
  >
332
- {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}
333
511
  </button>
334
512
  {/each}
335
513
 
336
514
  {#if dayEvents.length > 3}
337
- <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">
338
516
  +{dayEvents.length - 3} more
339
517
  </span>
340
518
  {/if}
@@ -356,6 +534,31 @@
356
534
  {/each}
357
535
  </div>
358
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
+
359
562
  <div class="grid grid-cols-8 divide-x divide-[var(--ui-border)] max-h-[600px] overflow-y-auto">
360
563
  <!-- Hours Column -->
361
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)]">
@@ -366,7 +569,8 @@
366
569
 
367
570
  <!-- 7 Days Grid Columns -->
368
571
  {#each weekDays as d}
369
- {@const dayEvents = getEventsForDay(d)}
572
+ {@const dayEvents = getTimedEventsForDay(d)}
573
+ {@const layoutEvents = computeOverlappingLayout(dayEvents)}
370
574
  {@const isCurrentDay = isToday(d)}
371
575
  {@const nowTime = getTimeInZone(new Date())}
372
576
  {@const nowMinutes = nowTime.hours * 60 + nowTime.minutes}
@@ -376,10 +580,19 @@
376
580
  <!-- svelte-ignore a11y_click_events_have_key_events -->
377
581
  <div
378
582
  onclick={() => openCreateModal(d)}
379
- 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"
380
584
  >
381
- {#each hours as _}
382
- <div class="h-14"></div>
585
+ {#each hours as hour}
586
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
587
+ <div
588
+ class="h-14 transition-colors hover:bg-[var(--ui-primary)]/10"
589
+ ondragover={(e) => e.preventDefault()}
590
+ ondrop={(e) => {
591
+ e.preventDefault();
592
+ e.stopPropagation();
593
+ handleEventDrop(d, hour);
594
+ }}
595
+ ></div>
383
596
  {/each}
384
597
 
385
598
  <!-- Google Calendar style Current Time Red Line -->
@@ -393,22 +606,50 @@
393
606
  </div>
394
607
  {/if}
395
608
 
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"
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"
625
+ draggable="true"
626
+ ondragstart={(e) => {
627
+ draggedEventId = ev.id;
628
+ e.dataTransfer?.setData('text/plain', ev.id);
629
+ }}
402
630
  onclick={(e) => {
403
631
  e.stopPropagation();
404
632
  onEventClick?.(ev);
405
633
  }}
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)'};"
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)'};"
408
642
  >
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>
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>
412
653
  {/each}
413
654
  </div>
414
655
  {/each}
@@ -432,23 +673,20 @@
432
673
  {/each}
433
674
  </div>
434
675
 
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
676
  <!-- svelte-ignore a11y_no_static_element_interactions -->
441
677
  <!-- svelte-ignore a11y_click_events_have_key_events -->
442
678
  <div
443
679
  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"
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"
445
681
  >
446
682
  {#each hours as _}
447
683
  <div class="h-16"></div>
448
684
  {/each}
449
685
 
450
686
  <!-- Google Calendar style Current Time Red Line -->
451
- {#if isCurrentDay}
687
+ {#if isToday(currentDate)}
688
+ {@const nowTime = getTimeInZone(new Date())}
689
+ {@const currentTimeTop = ((nowTime.hours * 60 + nowTime.minutes) / 60) * 64}
452
690
  <div
453
691
  class="absolute inset-x-0 z-30 pointer-events-none flex items-center"
454
692
  style="top: {currentTimeTop}px;"
@@ -458,21 +696,49 @@
458
696
  </div>
459
697
  {/if}
460
698
 
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"
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
+ }}
466
719
  onclick={(e) => {
467
720
  e.stopPropagation();
468
721
  onEventClick?.(ev);
469
722
  }}
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)'};"
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)'};"
472
731
  >
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>
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>
476
742
  {/each}
477
743
  </div>
478
744
  </div>
@@ -500,28 +766,30 @@
500
766
 
501
767
  <div class="space-y-3.5 text-xs">
502
768
  <div>
503
- <label class="block mb-1 font-semibold text-[var(--ui-muted-foreground)]">Title</label>
769
+ <label for="event-cal-title" class="block mb-1 font-semibold text-[var(--ui-muted-foreground)]">Title</label>
504
770
  <input
771
+ id="event-cal-title"
505
772
  type="text"
506
773
  bind:value={newEventTitle}
507
774
  placeholder="Event title (e.g. Sprint Review)..."
508
775
  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
776
  />
511
777
  </div>
512
778
 
513
779
  <div class="grid grid-cols-2 gap-3">
514
780
  <div>
515
- <label class="block mb-1 font-semibold text-[var(--ui-muted-foreground)]">Start Time</label>
781
+ <label for="event-cal-start-time" class="block mb-1 font-semibold text-[var(--ui-muted-foreground)]">Start Time</label>
516
782
  <input
783
+ id="event-cal-start-time"
517
784
  type="time"
518
785
  bind:value={newEventStartTime}
519
786
  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
787
  />
521
788
  </div>
522
789
  <div>
523
- <label class="block mb-1 font-semibold text-[var(--ui-muted-foreground)]">End Time</label>
790
+ <label for="event-cal-end-time" class="block mb-1 font-semibold text-[var(--ui-muted-foreground)]">End Time</label>
524
791
  <input
792
+ id="event-cal-end-time"
525
793
  type="time"
526
794
  bind:value={newEventEndTime}
527
795
  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)]"
@@ -530,7 +798,7 @@
530
798
  </div>
531
799
 
532
800
  <div>
533
- <label class="block mb-1.5 font-semibold text-[var(--ui-muted-foreground)]">Tag Color</label>
801
+ <span class="block mb-1.5 font-semibold text-[var(--ui-muted-foreground)]">Tag Color</span>
534
802
  <div class="flex items-center gap-2">
535
803
  {#each presetColors as col}
536
804
  <button
@@ -548,11 +816,10 @@
548
816
  </div>
549
817
 
550
818
  <div>
551
- <label class="block mb-1 font-semibold text-[var(--ui-muted-foreground)]">Description (optional)</label>
819
+ <label for="event-cal-desc" class="block mb-1 font-semibold text-[var(--ui-muted-foreground)]">Description (optional)</label>
552
820
  <textarea
821
+ id="event-cal-desc"
553
822
  bind:value={newEventDescription}
554
- rows={2}
555
- placeholder="Notes, agenda, or link..."
556
823
  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
824
  ></textarea>
558
825
  </div>
@@ -20,6 +20,16 @@ type $$ComponentProps = {
20
20
  onEventClick?: (event: CalendarEvent) => void;
21
21
  onDateClick?: (date: Date) => void;
22
22
  onAddEvent?: (newEvent: CalendarEvent) => void;
23
+ onEventReschedule?: (detail: {
24
+ event: CalendarEvent;
25
+ newStart: Date;
26
+ newEnd: Date;
27
+ }) => void;
28
+ onEventResize?: (detail: {
29
+ event: CalendarEvent;
30
+ newStart: Date;
31
+ newEnd: Date;
32
+ }) => void;
23
33
  };
24
34
  declare const EventCalendar: import("svelte").Component<$$ComponentProps, {}, "events" | "view" | "currentDate">;
25
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.11",
3
+ "version": "0.1.1-alpha.13",
4
4
  "description": "Calendar and DatePicker components for intinyagroup UI",
5
5
  "type": "module",
6
6
  "exports": {
@@ -19,8 +19,9 @@
19
19
  "dev": "svelte-package -o dist -w"
20
20
  },
21
21
  "dependencies": {
22
- "@intinyagroup/ui": "0.1.1-alpha.9",
23
- "date-fns": "^4.4.0"
22
+ "@intinyagroup/ui": "0.1.1-alpha.11",
23
+ "date-fns": "^4.4.0",
24
+ "lucide-svelte": "^1.0.1"
24
25
  },
25
26
  "devDependencies": {
26
27
  "@sveltejs/package": "^2.5.8",