@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.
@@ -273,6 +273,8 @@
273
273
  {@const isEdge = isStart || isEnd}
274
274
 
275
275
  <div
276
+ role="gridcell"
277
+ aria-selected={isEdge}
276
278
  class={cn(
277
279
  'relative py-0.5 flex items-center justify-center',
278
280
  inRange && !isEdge && 'bg-[var(--ui-primary)]/10',
@@ -44,6 +44,7 @@
44
44
  onEventClick,
45
45
  onDateClick,
46
46
  onAddEvent,
47
+ onDragCreate,
47
48
  onEventReschedule,
48
49
  }: {
49
50
  events?: CalendarEvent[];
@@ -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"></div>
783
+ {#each hours as hour}
784
+ <div class="h-16 pointer-events-auto"
785
+ onmousedown={(e) => handleDragCreateMouseDown(e, currentDate, hour, e.currentTarget.closest('[id="day-view-col"]')!)}
786
+ ></div>
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.13",
3
+ "version": "0.1.1-alpha.14",
4
4
  "description": "Calendar and DatePicker components for intinyagroup UI",
5
5
  "type": "module",
6
6
  "exports": {
@@ -16,10 +16,11 @@
16
16
  "scripts": {
17
17
  "build": "svelte-package -o dist",
18
18
  "check": "echo 'no check'",
19
- "dev": "svelte-package -o dist -w"
19
+ "dev": "svelte-package -o dist -w",
20
+ "test": "vitest run"
20
21
  },
21
22
  "dependencies": {
22
- "@intinyagroup/ui": "0.1.1-alpha.11",
23
+ "@intinyagroup/ui": "0.1.1-alpha.12",
23
24
  "date-fns": "^4.4.0",
24
25
  "lucide-svelte": "^1.0.1"
25
26
  },