nuxt-ui-elements-pro 0.1.0

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.
@@ -0,0 +1,646 @@
1
+ <script>
2
+ import theme from "#build/ui-elements-pro/event-calendar";
3
+ import { toCalendarDate } from "@internationalized/date";
4
+ import { computed, ref, onMounted, onBeforeUnmount } from "vue";
5
+ import { tv } from "../utils/tv";
6
+ import { hasTimeComponent, layoutTimedEvents } from "../utils/event-calendar";
7
+ import { useEventCalendarDragDrop } from "../composables/useEventCalendarDragDrop";
8
+ import {
9
+ today,
10
+ startOf,
11
+ add,
12
+ subtract,
13
+ format,
14
+ isSameMonth,
15
+ isToday as checkIsToday,
16
+ isWeekend as checkIsWeekend,
17
+ asCalendarDate,
18
+ asCalendarDateTime,
19
+ getLocalTimeZone,
20
+ getWeekStartLocale
21
+ } from "#std/date";
22
+ </script>
23
+
24
+ <script setup>
25
+ const {
26
+ events = [],
27
+ view = "month",
28
+ locale = "en-US",
29
+ weekStartsOn = 0,
30
+ editable = true,
31
+ color = "primary",
32
+ monthOptions: monthOpts,
33
+ weekOptions: weekOpts,
34
+ dayOptions: dayOpts,
35
+ ui: propUi,
36
+ ...props
37
+ } = defineProps({
38
+ events: { type: Array, required: false },
39
+ modelValue: { type: null, required: false },
40
+ view: { type: String, required: false },
41
+ locale: { type: String, required: false },
42
+ weekStartsOn: { type: Number, required: false },
43
+ editable: { type: Boolean, required: false },
44
+ color: { type: null, required: false },
45
+ monthOptions: { type: Object, required: false },
46
+ weekOptions: { type: Object, required: false },
47
+ dayOptions: { type: Object, required: false },
48
+ ui: { type: Object, required: false }
49
+ });
50
+ const emit = defineEmits(["update:modelValue", "update:view", "dateClick", "eventClick", "eventDrop"]);
51
+ defineSlots();
52
+ const monthConfig = computed(() => ({
53
+ maxEvents: monthOpts?.maxEvents ?? 3,
54
+ fixedWeeks: monthOpts?.fixedWeeks ?? true
55
+ }));
56
+ const weekConfig = computed(() => ({
57
+ startHour: weekOpts?.startHour ?? 7,
58
+ endHour: weekOpts?.endHour ?? 22,
59
+ slotDuration: weekOpts?.slotDuration ?? 30
60
+ }));
61
+ const dayConfig = computed(() => ({
62
+ startHour: dayOpts?.startHour ?? 7,
63
+ endHour: dayOpts?.endHour ?? 22,
64
+ slotDuration: dayOpts?.slotDuration ?? 30
65
+ }));
66
+ const ui = computed(
67
+ () => tv({
68
+ extend: tv(theme)
69
+ })({
70
+ color
71
+ })
72
+ );
73
+ const displayDate = computed(() => {
74
+ if (props.modelValue) {
75
+ return asCalendarDate(props.modelValue);
76
+ }
77
+ return today();
78
+ });
79
+ const weekLocale = computed(() => getWeekStartLocale(weekStartsOn));
80
+ function goToPrev() {
81
+ let target;
82
+ if (view === "month") {
83
+ target = startOf(subtract(displayDate.value, 1, "month"), "month");
84
+ } else if (view === "week") {
85
+ target = asCalendarDate(subtract(displayDate.value, 1, "week"));
86
+ } else {
87
+ target = asCalendarDate(subtract(displayDate.value, 1, "day"));
88
+ }
89
+ emit("update:modelValue", target);
90
+ }
91
+ function goToNext() {
92
+ let target;
93
+ if (view === "month") {
94
+ target = startOf(add(displayDate.value, 1, "month"), "month");
95
+ } else if (view === "week") {
96
+ target = asCalendarDate(add(displayDate.value, 1, "week"));
97
+ } else {
98
+ target = asCalendarDate(add(displayDate.value, 1, "day"));
99
+ }
100
+ emit("update:modelValue", target);
101
+ }
102
+ function goToToday() {
103
+ emit("update:modelValue", today());
104
+ }
105
+ function setView(v) {
106
+ emit("update:view", v);
107
+ }
108
+ const headerTitle = computed(() => {
109
+ if (view === "month") {
110
+ return format(displayDate.value, "MMMM YYYY", locale);
111
+ }
112
+ if (view === "week") {
113
+ const weekStart = startOf(displayDate.value, "week", weekLocale.value);
114
+ const weekEnd = asCalendarDate(add(weekStart, 6, "day"));
115
+ if (weekStart.month === weekEnd.month) {
116
+ return `${format(weekStart, "MMM D", locale)} \u2013 ${format(weekEnd, "D, YYYY", locale)}`;
117
+ }
118
+ return `${format(weekStart, "MMM D", locale)} \u2013 ${format(weekEnd, "MMM D, YYYY", locale)}`;
119
+ }
120
+ return format(displayDate.value, "dddd, MMMM D, YYYY", locale);
121
+ });
122
+ function parseDateInput(input) {
123
+ if (typeof input === "object" && "calendar" in input) {
124
+ return input;
125
+ }
126
+ if (input instanceof Date) {
127
+ const iso = input.toISOString();
128
+ if (iso.endsWith("T00:00:00.000Z")) {
129
+ return asCalendarDate(input);
130
+ }
131
+ return asCalendarDateTime(input);
132
+ }
133
+ if (typeof input === "string") {
134
+ if (input.includes("T") || input.includes(" ")) {
135
+ try {
136
+ return asCalendarDateTime(input);
137
+ } catch {
138
+ return asCalendarDate(input);
139
+ }
140
+ }
141
+ return asCalendarDate(input);
142
+ }
143
+ return today();
144
+ }
145
+ const normalizedEvents = computed(() => {
146
+ return events.map((event) => {
147
+ const start = parseDateInput(event.start);
148
+ const end = event.end ? parseDateInput(event.end) : start;
149
+ const allDay = event.allDay ?? !hasTimeComponent(start);
150
+ return {
151
+ id: event.id,
152
+ title: event.title,
153
+ start,
154
+ end,
155
+ color: event.color ?? "primary",
156
+ allDay,
157
+ draggable: event.draggable ?? true,
158
+ original: event
159
+ };
160
+ });
161
+ });
162
+ const eventsByDate = computed(() => {
163
+ const map = /* @__PURE__ */ new Map();
164
+ for (const event of normalizedEvents.value) {
165
+ const startDate = toCalendarDate(event.start);
166
+ const endDate = toCalendarDate(event.end);
167
+ let current = startDate;
168
+ while (current.compare(endDate) <= 0) {
169
+ const key = current.toString();
170
+ const existing = map.get(key) ?? [];
171
+ existing.push(event);
172
+ map.set(key, existing);
173
+ current = asCalendarDate(add(current, 1, "day"));
174
+ }
175
+ }
176
+ return map;
177
+ });
178
+ function getEventsForDate(date) {
179
+ return eventsByDate.value.get(date.toString()) ?? [];
180
+ }
181
+ const weekdayLabels = computed(() => {
182
+ const labels = [];
183
+ const refSunday = asCalendarDate("2025-01-05");
184
+ for (let i = 0; i < 7; i++) {
185
+ const day = asCalendarDate(add(refSunday, i + weekStartsOn, "day"));
186
+ labels.push(format(day, "ddd", locale));
187
+ }
188
+ return labels;
189
+ });
190
+ const monthWeeks = computed(() => {
191
+ const monthStart = startOf(displayDate.value, "month");
192
+ const gridStart = startOf(monthStart, "week", weekLocale.value);
193
+ const rowCount = monthConfig.value.fixedWeeks ? 6 : 5;
194
+ const result = [];
195
+ let current = gridStart;
196
+ for (let week = 0; week < rowCount; week++) {
197
+ const days = [];
198
+ for (let day = 0; day < 7; day++) {
199
+ const date = asCalendarDate(current);
200
+ days.push({
201
+ date,
202
+ isCurrentMonth: isSameMonth(date, monthStart),
203
+ isToday: checkIsToday(date, getLocalTimeZone()),
204
+ isWeekend: checkIsWeekend(date, weekLocale.value),
205
+ events: getEventsForDate(date)
206
+ });
207
+ current = asCalendarDate(add(current, 1, "day"));
208
+ }
209
+ result.push({ days });
210
+ }
211
+ return result;
212
+ });
213
+ const weekDays = computed(() => {
214
+ const weekStart = startOf(displayDate.value, "week", weekLocale.value);
215
+ const days = [];
216
+ for (let i = 0; i < 7; i++) {
217
+ const date = asCalendarDate(add(weekStart, i, "day"));
218
+ days.push({
219
+ date,
220
+ isCurrentMonth: true,
221
+ isToday: checkIsToday(date, getLocalTimeZone()),
222
+ isWeekend: checkIsWeekend(date, weekLocale.value),
223
+ events: getEventsForDate(date)
224
+ });
225
+ }
226
+ return days;
227
+ });
228
+ const dayViewDate = computed(() => {
229
+ const date = asCalendarDate(displayDate.value);
230
+ return {
231
+ date,
232
+ isCurrentMonth: true,
233
+ isToday: checkIsToday(date, getLocalTimeZone()),
234
+ isWeekend: checkIsWeekend(date, weekLocale.value),
235
+ events: getEventsForDate(date)
236
+ };
237
+ });
238
+ function generateTimeSlots(startHour, endHour, slotDuration) {
239
+ const slots = [];
240
+ const totalMinutes = (endHour - startHour) * 60;
241
+ for (let m = 0; m < totalMinutes; m += slotDuration) {
242
+ const hour = startHour + Math.floor(m / 60);
243
+ const minute = m % 60;
244
+ const h12 = hour % 12 || 12;
245
+ const ampm = hour >= 12 ? "pm" : "am";
246
+ const label = minute === 0 ? `${h12}${ampm}` : "";
247
+ slots.push({ hour, minute, label });
248
+ }
249
+ return slots;
250
+ }
251
+ const weekTimeSlots = computed(
252
+ () => generateTimeSlots(weekConfig.value.startHour, weekConfig.value.endHour, weekConfig.value.slotDuration)
253
+ );
254
+ const dayTimeSlots = computed(
255
+ () => generateTimeSlots(dayConfig.value.startHour, dayConfig.value.endHour, dayConfig.value.slotDuration)
256
+ );
257
+ const SLOT_HEIGHT = 48;
258
+ const timeGridDays = computed(() => {
259
+ if (view !== "week" && view !== "day") return [];
260
+ const days = view === "week" ? weekDays.value : [dayViewDate.value];
261
+ const config = view === "week" ? weekConfig.value : dayConfig.value;
262
+ return days.map((day) => ({
263
+ ...day,
264
+ dateKey: day.date.toString(),
265
+ allDayEvents: day.events.filter((e) => e.allDay || !hasTimeComponent(e.start)),
266
+ timedEvents: layoutTimedEvents(getEventsForDate(day.date), config, SLOT_HEIGHT)
267
+ }));
268
+ });
269
+ const timeGridSlots = computed(() => {
270
+ if (view === "week") return weekTimeSlots.value;
271
+ if (view === "day") return dayTimeSlots.value;
272
+ return [];
273
+ });
274
+ function formatTime(d) {
275
+ if (!hasTimeComponent(d)) return "";
276
+ const dt = d;
277
+ const h12 = dt.hour % 12 || 12;
278
+ const ampm = dt.hour >= 12 ? "pm" : "am";
279
+ const min = dt.minute > 0 ? `:${String(dt.minute).padStart(2, "0")}` : "";
280
+ return `${h12}${min}${ampm}`;
281
+ }
282
+ function getEventStyle(event, bgOpacity = 10) {
283
+ const c = event.color;
284
+ const cssVar = c === "neutral" ? "var(--ui-bg-inverted)" : `var(--ui-${c})`;
285
+ return {
286
+ "--event-color": cssVar,
287
+ backgroundColor: `color-mix(in oklch, ${cssVar} ${bgOpacity}%, transparent)`
288
+ };
289
+ }
290
+ function isEventStart(event, date) {
291
+ const startDate = toCalendarDate(event.start);
292
+ return date.compare(startDate) === 0;
293
+ }
294
+ function handleDateClick(day) {
295
+ emit("dateClick", day.date);
296
+ }
297
+ function handleEventClick(event, e) {
298
+ e.stopPropagation();
299
+ emit("eventClick", event.original);
300
+ }
301
+ const expandedDay = ref(null);
302
+ function openExpandDay(dateKey, e) {
303
+ e.stopPropagation();
304
+ expandedDay.value = dateKey;
305
+ }
306
+ function closeExpandDay() {
307
+ expandedDay.value = null;
308
+ }
309
+ function isExpanded(dateKey) {
310
+ return expandedDay.value === dateKey;
311
+ }
312
+ onMounted(() => {
313
+ document.addEventListener("click", closeExpandDay);
314
+ });
315
+ onBeforeUnmount(() => {
316
+ document.removeEventListener("click", closeExpandDay);
317
+ });
318
+ const {
319
+ draggedEventId,
320
+ dropTargetKey,
321
+ dragSnapSlot,
322
+ onDragStart,
323
+ onDragEnd,
324
+ onDragOver,
325
+ onDragLeave,
326
+ onTimeGridDragOver,
327
+ onSlotDragOver,
328
+ onDropMonthCell,
329
+ onDropTimeGrid
330
+ } = useEventCalendarDragDrop({
331
+ editable: () => editable,
332
+ normalizedEvents,
333
+ onEventDrop: (payload) => emit("eventDrop", payload)
334
+ });
335
+ </script>
336
+
337
+ <template>
338
+ <div data-slot="root" :class="ui.root({ class: propUi?.root })">
339
+ <!-- Header -->
340
+ <slot
341
+ name="header"
342
+ :title="headerTitle"
343
+ :prev="goToPrev"
344
+ :next="goToNext"
345
+ :today="goToToday"
346
+ :current-date="displayDate"
347
+ :view="view"
348
+ :set-view="setView">
349
+ <div data-slot="header" :class="ui.header({ class: propUi?.header })">
350
+ <span data-slot="headerTitle" :class="ui.headerTitle({ class: propUi?.headerTitle })">
351
+ {{ headerTitle }}
352
+ </span>
353
+
354
+ <div data-slot="headerActions" :class="ui.headerActions({ class: propUi?.headerActions })">
355
+ <!-- View switcher -->
356
+ <div data-slot="viewSwitcher" :class="ui.viewSwitcher({ class: propUi?.viewSwitcher })">
357
+ <UButton
358
+ v-for="v in ['month', 'week', 'day']"
359
+ :key="v"
360
+ :label="v"
361
+ size="xs"
362
+ :color="view === v ? color : 'neutral'"
363
+ :variant="view === v ? 'subtle' : 'ghost'"
364
+ class="capitalize"
365
+ @click="setView(v)" />
366
+ </div>
367
+
368
+ <UButton icon="i-lucide-chevron-left" color="neutral" variant="ghost" size="xs" square @click="goToPrev" />
369
+ <UButton label="Today" color="neutral" variant="ghost" size="xs" @click="goToToday" />
370
+ <UButton icon="i-lucide-chevron-right" color="neutral" variant="ghost" size="xs" square @click="goToNext" />
371
+ </div>
372
+ </div>
373
+ </slot>
374
+
375
+ <!-- ── Month View ──────────────────────────────────────────────── -->
376
+ <template v-if="view === 'month'">
377
+ <!-- Weekday headers -->
378
+ <div data-slot="weekdayRow" :class="ui.weekdayRow({ class: propUi?.weekdayRow })">
379
+ <div
380
+ v-for="(day, index) in weekdayLabels"
381
+ :key="day"
382
+ data-slot="weekdayCell"
383
+ :class="ui.weekdayCell({ class: propUi?.weekdayCell })">
384
+ <slot name="day-header" :day="day" :index="index">
385
+ {{ day }}
386
+ </slot>
387
+ </div>
388
+ </div>
389
+
390
+ <!-- Month grid -->
391
+ <div data-slot="monthBody" :class="ui.monthBody({ class: propUi?.monthBody })">
392
+ <template v-for="week in monthWeeks" :key="week.days[0].date.toString()">
393
+ <div
394
+ v-for="day in week.days"
395
+ :key="day.date.toString()"
396
+ data-slot="dayCell"
397
+ :class="[
398
+ ui.dayCell({ class: propUi?.dayCell }),
399
+ !day.isCurrentMonth && ui.dayCellOutside({ class: propUi?.dayCellOutside }),
400
+ dropTargetKey === `month-${day.date.toString()}` && ui.dropTarget({ class: propUi?.dropTarget }),
401
+ isExpanded(day.date.toString()) && 'relative z-10'
402
+ ]"
403
+ @click="handleDateClick(day)"
404
+ @dragover="onDragOver(`month-${day.date.toString()}`, $event)"
405
+ @dragleave="onDragLeave"
406
+ @drop="onDropMonthCell(day.date, $event)">
407
+ <slot name="day" :day="day">
408
+ <!-- Day number -->
409
+ <div
410
+ data-slot="dayNumber"
411
+ :class="[
412
+ !day.isToday && ui.dayNumber({ class: propUi?.dayNumber }),
413
+ day.isToday && ui.dayNumberToday({ class: propUi?.dayNumberToday }),
414
+ !day.isCurrentMonth && !day.isToday && ui.dayNumberOutside({ class: propUi?.dayNumberOutside })
415
+ ]">
416
+ {{ day.date.day }}
417
+ </div>
418
+
419
+ <!-- Event chips -->
420
+ <div data-slot="eventList" :class="ui.eventList({ class: propUi?.eventList })">
421
+ <template v-for="(event, idx) in day.events" :key="event.id">
422
+ <div
423
+ v-if="idx < monthConfig.maxEvents"
424
+ data-slot="eventChip"
425
+ :draggable="editable && event.draggable && isEventStart(event, day.date)"
426
+ :class="[ui.eventChip({ class: propUi?.eventChip }), !isEventStart(event, day.date) && 'border-l-0!']"
427
+ :style="getEventStyle(event)"
428
+ @click="handleEventClick(event, $event)"
429
+ @dragstart="onDragStart(event, $event)"
430
+ @dragend="onDragEnd">
431
+ <slot name="event" :event="event.original" :view="view">
432
+ <template v-if="isEventStart(event, day.date)">
433
+ <span v-if="!event.allDay" data-slot="eventTime" :class="ui.eventTime({ class: propUi?.eventTime })">{{
434
+ formatTime(event.start)
435
+ }}</span>
436
+ {{ event.title }}
437
+ </template>
438
+ <span v-else class="opacity-0">{{ event.title }}</span>
439
+ </slot>
440
+ </div>
441
+ </template>
442
+
443
+ <!-- "+N more" trigger -->
444
+ <div
445
+ v-if="day.events.length > monthConfig.maxEvents"
446
+ data-slot="moreEvents"
447
+ :class="ui.moreEvents({ class: propUi?.moreEvents })"
448
+ @click="openExpandDay(day.date.toString(), $event)">
449
+ <slot
450
+ name="more-events"
451
+ :events="day.events.slice(monthConfig.maxEvents).map((e) => e.original)"
452
+ :count="day.events.length - monthConfig.maxEvents"
453
+ :day="day">
454
+ +{{ day.events.length - monthConfig.maxEvents }} more
455
+ </slot>
456
+ </div>
457
+ </div>
458
+
459
+ <!-- Expanded overlay with ALL events -->
460
+ <div
461
+ v-if="isExpanded(day.date.toString()) && day.events.length > monthConfig.maxEvents"
462
+ data-slot="expandedOverlay"
463
+ :class="ui.expandedOverlay({ class: propUi?.expandedOverlay })"
464
+ @click.stop>
465
+ <div
466
+ data-slot="dayNumber"
467
+ :class="[
468
+ !day.isToday && ui.dayNumber({ class: propUi?.dayNumber }),
469
+ day.isToday && ui.dayNumberToday({ class: propUi?.dayNumberToday })
470
+ ]">
471
+ {{ day.date.day }}
472
+ </div>
473
+ <div data-slot="eventList" :class="ui.eventList({ class: propUi?.eventList })">
474
+ <div
475
+ v-for="event in day.events"
476
+ :key="`expanded-${event.id}`"
477
+ data-slot="eventChip"
478
+ :draggable="editable && event.draggable"
479
+ :class="ui.eventChip({ class: propUi?.eventChip })"
480
+ :style="getEventStyle(event)"
481
+ @click="handleEventClick(event, $event)"
482
+ @dragstart="onDragStart(event, $event)"
483
+ @dragend="onDragEnd">
484
+ <slot name="event" :event="event.original" :view="view">
485
+ <span v-if="!event.allDay" data-slot="eventTime" :class="ui.eventTime({ class: propUi?.eventTime })">{{
486
+ formatTime(event.start)
487
+ }}</span
488
+ >{{ event.title }}
489
+ </slot>
490
+ </div>
491
+ </div>
492
+ </div>
493
+ </slot>
494
+ </div>
495
+ </template>
496
+ </div>
497
+ </template>
498
+
499
+ <!-- ── Week / Day View (unified) ───────────────────────────────── -->
500
+ <template v-else-if="view === 'week' || view === 'day'">
501
+ <!-- Column headers -->
502
+ <div data-slot="columnHeaders" :class="ui.columnHeaders({ class: propUi?.columnHeaders })">
503
+ <div data-slot="timeGutterSpacer" :class="ui.timeGutterSpacer({ class: propUi?.timeGutterSpacer })" />
504
+ <div
505
+ v-for="day in timeGridDays"
506
+ :key="day.dateKey"
507
+ data-slot="dayColumnHeader"
508
+ :class="ui.dayColumnHeader({ class: propUi?.dayColumnHeader })">
509
+ <div :class="ui.dayColumnHeaderLabel({ class: propUi?.dayColumnHeaderLabel })">
510
+ {{ format(day.date, timeGridDays.length === 1 ? "dddd" : "ddd", locale) }}
511
+ </div>
512
+ <div
513
+ :class="[
514
+ day.isToday ? ui.dayColumnHeaderToday({ class: propUi?.dayColumnHeaderToday }) : ui.dayColumnHeaderNumber({ class: propUi?.dayColumnHeaderNumber })
515
+ ]">
516
+ {{ day.date.day }}
517
+ </div>
518
+ </div>
519
+ </div>
520
+
521
+ <!-- All-day events row -->
522
+ <div data-slot="allDayRow" :class="ui.allDayRow({ class: propUi?.allDayRow })">
523
+ <div data-slot="allDayLabel" :class="ui.allDayLabel({ class: propUi?.allDayLabel })">all-day</div>
524
+ <div
525
+ v-for="day in timeGridDays"
526
+ :key="`allday-${day.dateKey}`"
527
+ data-slot="allDayCell"
528
+ :class="[
529
+ ui.allDayCell({ class: propUi?.allDayCell }),
530
+ dropTargetKey === `allday-${day.dateKey}` && ui.dropTarget({ class: propUi?.dropTarget })
531
+ ]"
532
+ @dragover="onDragOver(`allday-${day.dateKey}`, $event)"
533
+ @dragleave="onDragLeave"
534
+ @drop="onDropMonthCell(day.date, $event)">
535
+ <!-- Day view exposes the all-day slot; week view renders chips directly -->
536
+ <template v-if="timeGridDays.length === 1">
537
+ <slot name="all-day" :events="day.allDayEvents.map((e) => e.original)" :date="day.date">
538
+ <div
539
+ v-for="event in day.allDayEvents"
540
+ :key="event.id"
541
+ :draggable="editable && event.draggable"
542
+ :class="[ui.eventChip({ class: propUi?.eventChip }), 'w-full']"
543
+ :style="getEventStyle(event)"
544
+ @click="handleEventClick(event, $event)"
545
+ @dragstart="onDragStart(event, $event)"
546
+ @dragend="onDragEnd">
547
+ <slot name="event" :event="event.original" :view="view">
548
+ {{ event.title }}
549
+ </slot>
550
+ </div>
551
+ </slot>
552
+ </template>
553
+ <template v-else>
554
+ <div
555
+ v-for="event in day.allDayEvents"
556
+ :key="event.id"
557
+ :draggable="editable && event.draggable"
558
+ :class="[ui.eventChip({ class: propUi?.eventChip }), 'w-full']"
559
+ :style="getEventStyle(event)"
560
+ @click="handleEventClick(event, $event)"
561
+ @dragstart="onDragStart(event, $event)"
562
+ @dragend="onDragEnd">
563
+ <slot name="event" :event="event.original" :view="view">
564
+ {{ event.title }}
565
+ </slot>
566
+ </div>
567
+ </template>
568
+ </div>
569
+ </div>
570
+
571
+ <!-- Time grid -->
572
+ <div data-slot="timeGrid" :class="ui.timeGrid({ class: propUi?.timeGrid })">
573
+ <!-- Time gutter -->
574
+ <div data-slot="timeGutter" :class="ui.timeGutter({ class: propUi?.timeGutter })">
575
+ <div
576
+ v-for="slot in timeGridSlots"
577
+ :key="`gutter-${slot.hour}-${slot.minute}`"
578
+ data-slot="timeGutterSlot"
579
+ :class="ui.timeGutterSlot({ class: propUi?.timeGutterSlot })"
580
+ :style="{ height: `${SLOT_HEIGHT}px` }">
581
+ <div v-if="slot.label" data-slot="timeLabel" :class="ui.timeLabel({ class: propUi?.timeLabel })">
582
+ <slot name="time-label" :hour="slot.hour" :label="slot.label">
583
+ {{ slot.label }}
584
+ </slot>
585
+ </div>
586
+ </div>
587
+ </div>
588
+
589
+ <!-- Day columns -->
590
+ <div
591
+ v-for="day in timeGridDays"
592
+ :key="`col-${day.dateKey}`"
593
+ data-slot="dayColumn"
594
+ :class="ui.dayColumn({ class: propUi?.dayColumn })"
595
+ @dragover="onTimeGridDragOver(`time-${day.dateKey}`, $event)"
596
+ @dragleave="onDragLeave"
597
+ @drop="onDropTimeGrid(day.date, $event)">
598
+ <!-- Time slot rows (grid lines + drag targets) -->
599
+ <div
600
+ v-for="slot in timeGridSlots"
601
+ :key="`row-${slot.hour}-${slot.minute}`"
602
+ data-slot="timeSlotRow"
603
+ :class="[
604
+ ui.timeSlotRow({ class: propUi?.timeSlotRow }),
605
+ dragSnapSlot === `time-${day.dateKey}-${slot.hour}-${slot.minute}` && ui.dragSnapSlot({ class: propUi?.dragSnapSlot })
606
+ ]"
607
+ :style="{ height: `${SLOT_HEIGHT}px` }"
608
+ @click="handleDateClick(day)"
609
+ @dragover="onSlotDragOver(`time-${day.dateKey}-${slot.hour}-${slot.minute}`, $event)" />
610
+
611
+ <!-- Timed events (absolutely positioned) -->
612
+ <div
613
+ v-for="event in day.timedEvents"
614
+ :key="`ev-${event.id}`"
615
+ data-slot="timeEvent"
616
+ :draggable="editable && event.draggable"
617
+ :class="[
618
+ ui.timeEvent({ class: propUi?.timeEvent }),
619
+ draggedEventId != null && draggedEventId !== event.id && 'pointer-events-none'
620
+ ]"
621
+ :style="{
622
+ ...getEventStyle(event, 15),
623
+ top: `${event.topPx}px`,
624
+ height: `${event.heightPx}px`,
625
+ minHeight: '20px',
626
+ left: `calc(${event.column / event.totalColumns * 100}% + 2px)`,
627
+ width: `calc(${1 / event.totalColumns * 100}% - 4px)`
628
+ }"
629
+ @click="handleEventClick(event, $event)"
630
+ @dragstart="onDragStart(event, $event)"
631
+ @dragend="onDragEnd">
632
+ <slot name="event" :event="event.original" :view="view">
633
+ <div data-slot="timeEventTitle" :class="ui.timeEventTitle({ class: propUi?.timeEventTitle })">
634
+ {{ event.title }}
635
+ </div>
636
+ <div data-slot="timeEventTime" :class="ui.timeEventTime({ class: propUi?.timeEventTime })">
637
+ {{ formatTime(event.start) }}
638
+ <template v-if="timeGridDays.length === 1"> – {{ formatTime(event.end) }}</template>
639
+ </div>
640
+ </slot>
641
+ </div>
642
+ </div>
643
+ </div>
644
+ </template>
645
+ </div>
646
+ </template>