@intinyagroup/calendar 0.1.1-alpha.13 → 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.
|
@@ -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,18 @@
|
|
|
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);
|
|
68
75
|
|
|
69
76
|
function isMultiDayOrAllDay(ev: CalendarEvent): boolean {
|
|
70
77
|
if (ev.allDay) return true;
|
|
@@ -137,6 +144,84 @@
|
|
|
137
144
|
window.addEventListener('mousemove', onMouseMove);
|
|
138
145
|
window.addEventListener('mouseup', onMouseUp);
|
|
139
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
|
+
}
|
|
140
225
|
|
|
141
226
|
type LayoutEvent = CalendarEvent & {
|
|
142
227
|
colIndex: number;
|
|
@@ -578,14 +663,15 @@
|
|
|
578
663
|
|
|
579
664
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
580
665
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
581
|
-
<div
|
|
666
|
+
<div id="week-day-col"
|
|
582
667
|
onclick={() => openCreateModal(d)}
|
|
583
668
|
class="col-span-1 divide-y divide-[var(--ui-border)]/50 relative hover:bg-[var(--ui-secondary)]/10 cursor-pointer select-none"
|
|
584
669
|
>
|
|
585
670
|
{#each hours as hour}
|
|
586
671
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
587
672
|
<div
|
|
588
|
-
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"]')!)}
|
|
589
675
|
ondragover={(e) => e.preventDefault()}
|
|
590
676
|
ondrop={(e) => {
|
|
591
677
|
e.preventDefault();
|
|
@@ -651,6 +737,21 @@
|
|
|
651
737
|
></div>
|
|
652
738
|
</div>
|
|
653
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}
|
|
654
755
|
</div>
|
|
655
756
|
{/each}
|
|
656
757
|
</div>
|
|
@@ -675,12 +776,14 @@
|
|
|
675
776
|
|
|
676
777
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
677
778
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
678
|
-
<div
|
|
779
|
+
<div id="day-view-col"
|
|
679
780
|
onclick={() => openCreateModal(currentDate)}
|
|
680
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"
|
|
681
782
|
>
|
|
682
|
-
{#each hours as
|
|
683
|
-
<div class="h-16"
|
|
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>
|
|
684
787
|
{/each}
|
|
685
788
|
|
|
686
789
|
<!-- Google Calendar style Current Time Red Line -->
|
|
@@ -740,6 +843,21 @@
|
|
|
740
843
|
></div>
|
|
741
844
|
</div>
|
|
742
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}
|
|
743
861
|
</div>
|
|
744
862
|
</div>
|
|
745
863
|
{/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.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.
|
|
23
|
+
"@intinyagroup/ui": "0.1.1-alpha.12",
|
|
23
24
|
"date-fns": "^4.4.0",
|
|
24
25
|
"lucide-svelte": "^1.0.1"
|
|
25
26
|
},
|