@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.
- package/dist/DateRangePicker.svelte +2 -0
- package/dist/EventCalendar.svelte +178 -33
- package/dist/EventCalendar.svelte.d.ts +4 -0
- package/dist/helpers.test.d.ts +1 -0
- package/dist/helpers.test.js +129 -0
- package/package.json +4 -3
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
|
|
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
|
|
309
|
-
const
|
|
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-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
633
|
+
{#each (isMobile ? mobileWeekDays : weekDays) as d}
|
|
528
634
|
<div class="col-span-1 flex flex-col items-center">
|
|
529
|
-
<span class="capitalize">{
|
|
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
|
-
<!--
|
|
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"
|
|
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.
|
|
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.
|
|
23
|
+
"@intinyagroup/ui": "0.1.1-alpha.13",
|
|
23
24
|
"date-fns": "^4.4.0",
|
|
24
25
|
"lucide-svelte": "^1.0.1"
|
|
25
26
|
},
|