picocrank 1.29.0 → 1.30.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.
- package/package.json +1 -1
- package/vue/components/Calendar.vue +488 -37
- package/vue/examples/CalendarExample.vue +71 -23
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
-
import { ref, computed, watch, onUnmounted } from 'vue'
|
|
2
|
+
import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
|
|
3
3
|
import { HugeiconsIcon } from '@hugeicons/vue'
|
|
4
4
|
|
|
5
5
|
const props = defineProps({
|
|
@@ -62,6 +62,15 @@ const props = defineProps({
|
|
|
62
62
|
type: Number,
|
|
63
63
|
default: undefined,
|
|
64
64
|
},
|
|
65
|
+
viewMode: {
|
|
66
|
+
type: String,
|
|
67
|
+
default: 'month',
|
|
68
|
+
validator: (value) => ['month', 'week', 'day'].includes(value),
|
|
69
|
+
},
|
|
70
|
+
focusDate: {
|
|
71
|
+
type: [Date, String, Number],
|
|
72
|
+
default: null,
|
|
73
|
+
},
|
|
65
74
|
})
|
|
66
75
|
|
|
67
76
|
const emit = defineEmits([
|
|
@@ -73,6 +82,9 @@ const emit = defineEmits([
|
|
|
73
82
|
'event-move-request',
|
|
74
83
|
'event-moved',
|
|
75
84
|
'event-move-rejected',
|
|
85
|
+
'update:viewMode',
|
|
86
|
+
'update:focusDate',
|
|
87
|
+
'view-change',
|
|
76
88
|
])
|
|
77
89
|
|
|
78
90
|
// Internal calendar state
|
|
@@ -87,20 +99,91 @@ const currentDate = computed(() => {
|
|
|
87
99
|
const viewMonth = computed(() => currentDate.value.getMonth())
|
|
88
100
|
const viewYear = computed(() => currentDate.value.getFullYear())
|
|
89
101
|
|
|
102
|
+
// Monday-start week containing the given date
|
|
103
|
+
function getWeekStart(date) {
|
|
104
|
+
const d = new Date(date)
|
|
105
|
+
const dayOfWeek = (d.getDay() + 6) % 7 // Monday=0
|
|
106
|
+
d.setDate(d.getDate() - dayOfWeek)
|
|
107
|
+
d.setHours(0, 0, 0, 0)
|
|
108
|
+
return d
|
|
109
|
+
}
|
|
110
|
+
|
|
90
111
|
// Compute the first visible day (Monday) for the current month view
|
|
91
112
|
function getStartOfGrid(date) {
|
|
92
|
-
|
|
93
|
-
const dayOfWeek = (firstOfMonth.getDay() + 6) % 7 // Monday=0
|
|
94
|
-
const start = new Date(firstOfMonth)
|
|
95
|
-
start.setDate(firstOfMonth.getDate() - dayOfWeek)
|
|
96
|
-
start.setHours(0, 0, 0, 0)
|
|
97
|
-
return start
|
|
113
|
+
return getWeekStart(new Date(date.getFullYear(), date.getMonth(), 1))
|
|
98
114
|
}
|
|
99
115
|
|
|
100
|
-
|
|
116
|
+
function startOfDay(d) {
|
|
117
|
+
const x = new Date(d)
|
|
118
|
+
x.setHours(0, 0, 0, 0)
|
|
119
|
+
return x
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function parseFocusDate(value) {
|
|
123
|
+
if (value == null) {
|
|
124
|
+
return startOfDay(new Date())
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const parsed = value instanceof Date ? new Date(value) : new Date(value)
|
|
128
|
+
if (Number.isNaN(parsed.getTime())) {
|
|
129
|
+
return startOfDay(new Date())
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return startOfDay(parsed)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function normalizeViewAnchor(mode, focusDateValue) {
|
|
136
|
+
const focus = parseFocusDate(focusDateValue)
|
|
137
|
+
|
|
138
|
+
if (mode === 'week') {
|
|
139
|
+
return getWeekStart(focus)
|
|
140
|
+
}
|
|
141
|
+
if (mode === 'day') {
|
|
142
|
+
return focus
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return getStartOfGrid(currentDate.value)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Anchor date for the grid; we will shift this by weeks without re-rendering the grid structure
|
|
101
149
|
const gridStartDate = ref(getStartOfGrid(currentDate.value))
|
|
102
150
|
// Track if we're actively scrolling to prevent watcher from resetting grid anchor
|
|
103
151
|
const isScrolling = ref(false)
|
|
152
|
+
const internalViewMode = ref(props.viewMode)
|
|
153
|
+
const isCompactView = computed(() => internalViewMode.value !== 'month')
|
|
154
|
+
|
|
155
|
+
const headerTitle = computed(() => {
|
|
156
|
+
if (internalViewMode.value === 'month') {
|
|
157
|
+
return `${props.monthNames[viewMonth.value]} ${viewYear.value}`
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (internalViewMode.value === 'day') {
|
|
161
|
+
const day = gridStartDate.value
|
|
162
|
+
return day.toLocaleDateString(undefined, {
|
|
163
|
+
weekday: 'long',
|
|
164
|
+
month: 'long',
|
|
165
|
+
day: 'numeric',
|
|
166
|
+
year: 'numeric',
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const start = gridStartDate.value
|
|
171
|
+
const end = new Date(start)
|
|
172
|
+
end.setDate(start.getDate() + 6)
|
|
173
|
+
|
|
174
|
+
const startMonth = props.monthNames[start.getMonth()]
|
|
175
|
+
const endMonth = props.monthNames[end.getMonth()]
|
|
176
|
+
const startYear = start.getFullYear()
|
|
177
|
+
const endYear = end.getFullYear()
|
|
178
|
+
|
|
179
|
+
if (startYear === endYear && start.getMonth() === end.getMonth()) {
|
|
180
|
+
return `${startMonth} ${start.getDate()} – ${end.getDate()}, ${startYear}`
|
|
181
|
+
}
|
|
182
|
+
if (startYear === endYear) {
|
|
183
|
+
return `${startMonth} ${start.getDate()} – ${endMonth} ${end.getDate()}, ${startYear}`
|
|
184
|
+
}
|
|
185
|
+
return `${startMonth} ${start.getDate()}, ${startYear} – ${endMonth} ${end.getDate()}, ${endYear}`
|
|
186
|
+
})
|
|
104
187
|
|
|
105
188
|
// Helper function to check if a time is midnight (00:00)
|
|
106
189
|
function isMidnight(dateValue) {
|
|
@@ -260,11 +343,33 @@ function getOrdinalSuffix(day) {
|
|
|
260
343
|
return 'th'
|
|
261
344
|
}
|
|
262
345
|
|
|
346
|
+
function calendarDayCount(mode = internalViewMode.value) {
|
|
347
|
+
if (mode === 'week') return 7
|
|
348
|
+
if (mode === 'day') return 1
|
|
349
|
+
return 42
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function sortedEventsForDay(day) {
|
|
353
|
+
return [...day.events].sort((a, b) => {
|
|
354
|
+
const aMultiDay = isMultiDayEvent(a)
|
|
355
|
+
const bMultiDay = isMultiDayEvent(b)
|
|
356
|
+
if (aMultiDay && !bMultiDay) return -1
|
|
357
|
+
if (!aMultiDay && bMultiDay) return 1
|
|
358
|
+
return 0
|
|
359
|
+
})
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function visibleEventsForDay(day) {
|
|
363
|
+
const sorted = sortedEventsForDay(day)
|
|
364
|
+
return internalViewMode.value === 'day' ? sorted : sorted.slice(0, 3)
|
|
365
|
+
}
|
|
366
|
+
|
|
263
367
|
// Calendar generation from persistent grid start
|
|
264
368
|
const calendarDays = computed(() => {
|
|
265
369
|
const days = []
|
|
266
370
|
const start = gridStartDate.value
|
|
267
|
-
|
|
371
|
+
const dayCount = calendarDayCount()
|
|
372
|
+
for (let i = 0; i < dayCount; i++) {
|
|
268
373
|
const date = new Date(start)
|
|
269
374
|
date.setDate(start.getDate() + i)
|
|
270
375
|
days.push({
|
|
@@ -275,8 +380,68 @@ const calendarDays = computed(() => {
|
|
|
275
380
|
return days
|
|
276
381
|
})
|
|
277
382
|
|
|
383
|
+
function commitViewChange(mode, focusDateValue, { emitUpdates = true } = {}) {
|
|
384
|
+
const gridStart = normalizeViewAnchor(mode, focusDateValue)
|
|
385
|
+
internalViewMode.value = mode
|
|
386
|
+
gridStartDate.value = gridStart
|
|
387
|
+
|
|
388
|
+
if (mode !== 'month') {
|
|
389
|
+
syncMonthFromGridStart()
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (emitUpdates) {
|
|
393
|
+
emit('update:viewMode', mode)
|
|
394
|
+
emit('update:focusDate', mode === 'month' ? null : gridStart)
|
|
395
|
+
emit('view-change', { mode, focusDate: mode === 'month' ? null : gridStart })
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function applyExternalView(mode, focusDateValue) {
|
|
400
|
+
const gridStart = normalizeViewAnchor(mode, focusDateValue)
|
|
401
|
+
if (
|
|
402
|
+
mode === internalViewMode.value
|
|
403
|
+
&& toDateKey(gridStartDate.value) === toDateKey(gridStart)
|
|
404
|
+
) {
|
|
405
|
+
return
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
internalViewMode.value = mode
|
|
409
|
+
gridStartDate.value = gridStart
|
|
410
|
+
if (mode !== 'month') {
|
|
411
|
+
syncMonthFromGridStart()
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
watch(
|
|
416
|
+
() => [props.viewMode, props.focusDate],
|
|
417
|
+
([mode, focusDateValue]) => {
|
|
418
|
+
applyExternalView(mode, focusDateValue)
|
|
419
|
+
},
|
|
420
|
+
{ immediate: true },
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
function syncMonthFromGridStart() {
|
|
424
|
+
const { month, year } = getVisibleMonthYear(gridStartDate.value, internalViewMode.value)
|
|
425
|
+
if (props.currentMonth !== undefined && props.currentYear !== undefined) {
|
|
426
|
+
if (month !== viewMonth.value || year !== viewYear.value) {
|
|
427
|
+
emit('month-change', month, year)
|
|
428
|
+
}
|
|
429
|
+
} else {
|
|
430
|
+
internalCurrentDate.value = new Date(year, month, 1)
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
278
434
|
// Navigation functions
|
|
279
435
|
function previousMonth() {
|
|
436
|
+
if (internalViewMode.value === 'week') {
|
|
437
|
+
adjustByWeeks(-1)
|
|
438
|
+
return
|
|
439
|
+
}
|
|
440
|
+
if (internalViewMode.value === 'day') {
|
|
441
|
+
adjustByDays(-1)
|
|
442
|
+
return
|
|
443
|
+
}
|
|
444
|
+
|
|
280
445
|
if (props.currentMonth !== undefined && props.currentYear !== undefined) {
|
|
281
446
|
const newMonth = viewMonth.value === 0 ? 11 : viewMonth.value - 1
|
|
282
447
|
const newYear = viewMonth.value === 0 ? viewYear.value - 1 : viewYear.value
|
|
@@ -290,6 +455,15 @@ function previousMonth() {
|
|
|
290
455
|
}
|
|
291
456
|
|
|
292
457
|
function nextMonth() {
|
|
458
|
+
if (internalViewMode.value === 'week') {
|
|
459
|
+
adjustByWeeks(1)
|
|
460
|
+
return
|
|
461
|
+
}
|
|
462
|
+
if (internalViewMode.value === 'day') {
|
|
463
|
+
adjustByDays(1)
|
|
464
|
+
return
|
|
465
|
+
}
|
|
466
|
+
|
|
293
467
|
if (props.currentMonth !== undefined && props.currentYear !== undefined) {
|
|
294
468
|
const newMonth = viewMonth.value === 11 ? 0 : viewMonth.value + 1
|
|
295
469
|
const newYear = viewMonth.value === 11 ? viewYear.value + 1 : viewYear.value
|
|
@@ -302,8 +476,17 @@ function nextMonth() {
|
|
|
302
476
|
}
|
|
303
477
|
|
|
304
478
|
function goToToday() {
|
|
479
|
+
const today = new Date()
|
|
480
|
+
if (internalViewMode.value === 'week') {
|
|
481
|
+
commitViewChange('week', today)
|
|
482
|
+
return
|
|
483
|
+
}
|
|
484
|
+
if (internalViewMode.value === 'day') {
|
|
485
|
+
commitViewChange('day', today)
|
|
486
|
+
return
|
|
487
|
+
}
|
|
488
|
+
|
|
305
489
|
if (props.currentMonth !== undefined && props.currentYear !== undefined) {
|
|
306
|
-
const today = new Date()
|
|
307
490
|
emit('month-change', today.getMonth(), today.getFullYear())
|
|
308
491
|
gridStartDate.value = getStartOfGrid(today)
|
|
309
492
|
} else {
|
|
@@ -315,33 +498,49 @@ function goToToday() {
|
|
|
315
498
|
// Week navigation (mouse wheel)
|
|
316
499
|
const lastWheelAt = ref(0)
|
|
317
500
|
const wheelThrottleMs = 180
|
|
318
|
-
function getVisibleMonthYear(start) {
|
|
501
|
+
function getVisibleMonthYear(start, mode = internalViewMode.value) {
|
|
502
|
+
if (mode === 'day') {
|
|
503
|
+
return { month: start.getMonth(), year: start.getFullYear() }
|
|
504
|
+
}
|
|
505
|
+
|
|
319
506
|
const mid = new Date(start)
|
|
320
|
-
mid.setDate(start.getDate() +
|
|
507
|
+
mid.setDate(start.getDate() + (mode === 'week' ? 3 : 21))
|
|
321
508
|
return { month: mid.getMonth(), year: mid.getFullYear() }
|
|
322
509
|
}
|
|
323
510
|
|
|
324
|
-
function
|
|
325
|
-
const newStart = new Date(gridStartDate.value)
|
|
326
|
-
newStart.setDate(newStart.getDate() + weeks * 7)
|
|
327
|
-
gridStartDate.value = newStart
|
|
328
|
-
|
|
511
|
+
function syncNavigationMonth(newStart) {
|
|
329
512
|
if (props.currentMonth !== undefined && props.currentYear !== undefined) {
|
|
330
513
|
const { month, year } = getVisibleMonthYear(newStart)
|
|
331
514
|
if (month !== viewMonth.value || year !== viewYear.value) {
|
|
332
515
|
emit('month-change', month, year)
|
|
333
|
-
// Set flag to prevent watcher from resetting our scroll position
|
|
334
516
|
isScrolling.value = true
|
|
335
|
-
// Clear the flag after a short delay to allow future prop-driven updates
|
|
336
517
|
setTimeout(() => { isScrolling.value = false }, 100)
|
|
337
518
|
}
|
|
338
519
|
} else {
|
|
339
|
-
// Keep internal anchor and current date roughly aligned (set to first of visible month)
|
|
340
520
|
const { month, year } = getVisibleMonthYear(newStart)
|
|
341
521
|
internalCurrentDate.value = new Date(year, month, 1)
|
|
342
522
|
}
|
|
343
523
|
}
|
|
344
524
|
|
|
525
|
+
function adjustByWeeks(weeks) {
|
|
526
|
+
const newStart = new Date(gridStartDate.value)
|
|
527
|
+
newStart.setDate(newStart.getDate() + weeks * 7)
|
|
528
|
+
|
|
529
|
+
if (internalViewMode.value === 'month') {
|
|
530
|
+
gridStartDate.value = newStart
|
|
531
|
+
syncNavigationMonth(newStart)
|
|
532
|
+
return
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
commitViewChange(internalViewMode.value, newStart)
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function adjustByDays(days) {
|
|
539
|
+
const newStart = new Date(gridStartDate.value)
|
|
540
|
+
newStart.setDate(newStart.getDate() + days)
|
|
541
|
+
commitViewChange('day', newStart)
|
|
542
|
+
}
|
|
543
|
+
|
|
345
544
|
function handleWheel(event) {
|
|
346
545
|
// Prevent page scrolling while using calendar scroll
|
|
347
546
|
event.preventDefault()
|
|
@@ -349,6 +548,10 @@ function handleWheel(event) {
|
|
|
349
548
|
if (now - lastWheelAt.value < wheelThrottleMs) return
|
|
350
549
|
lastWheelAt.value = now
|
|
351
550
|
const direction = event.deltaY > 0 ? 1 : -1
|
|
551
|
+
if (internalViewMode.value === 'day') {
|
|
552
|
+
adjustByDays(direction)
|
|
553
|
+
return
|
|
554
|
+
}
|
|
352
555
|
adjustByWeeks(direction)
|
|
353
556
|
}
|
|
354
557
|
|
|
@@ -363,14 +566,133 @@ function handleContextMenu(event, mouseEvent) {
|
|
|
363
566
|
emit('event-context-menu', event, mouseEvent)
|
|
364
567
|
}
|
|
365
568
|
|
|
366
|
-
// ---
|
|
569
|
+
// --- Day context menu ---
|
|
367
570
|
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
571
|
+
const DAY_CONTEXT_MENU_MARGIN_PX = 8
|
|
572
|
+
const dayContextMenuOpen = ref(false)
|
|
573
|
+
const dayContextMenuStyle = ref({ top: '0px', left: '0px' })
|
|
574
|
+
const dayContextMenuDate = ref(null)
|
|
575
|
+
const dayContextMenuRef = ref(null)
|
|
576
|
+
|
|
577
|
+
function positionDayContextMenu(clientX, clientY) {
|
|
578
|
+
const menu = dayContextMenuRef.value
|
|
579
|
+
if (!menu) return
|
|
580
|
+
|
|
581
|
+
const rect = menu.getBoundingClientRect()
|
|
582
|
+
const margin = DAY_CONTEXT_MENU_MARGIN_PX
|
|
583
|
+
let top = clientY
|
|
584
|
+
let left = clientX
|
|
585
|
+
|
|
586
|
+
if (left + rect.width > window.innerWidth - margin) {
|
|
587
|
+
left = window.innerWidth - rect.width - margin
|
|
588
|
+
}
|
|
589
|
+
if (top + rect.height > window.innerHeight - margin) {
|
|
590
|
+
top = window.innerHeight - rect.height - margin
|
|
591
|
+
}
|
|
592
|
+
if (left < margin) left = margin
|
|
593
|
+
if (top < margin) top = margin
|
|
594
|
+
|
|
595
|
+
dayContextMenuStyle.value = {
|
|
596
|
+
top: `${top}px`,
|
|
597
|
+
left: `${left}px`,
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function closeDayContextMenu() {
|
|
602
|
+
dayContextMenuOpen.value = false
|
|
603
|
+
dayContextMenuDate.value = null
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function onDocumentPointerDownForDayMenu(event) {
|
|
607
|
+
if (!dayContextMenuOpen.value) return
|
|
608
|
+
if (dayContextMenuRef.value?.contains(event.target)) return
|
|
609
|
+
closeDayContextMenu()
|
|
372
610
|
}
|
|
373
611
|
|
|
612
|
+
function onDocumentKeyDownForDayMenu(event) {
|
|
613
|
+
if (!dayContextMenuOpen.value) return
|
|
614
|
+
if (event.key === 'Escape') {
|
|
615
|
+
closeDayContextMenu()
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function attachDayContextMenuListeners() {
|
|
620
|
+
window.addEventListener('pointerdown', onDocumentPointerDownForDayMenu, true)
|
|
621
|
+
window.addEventListener('keydown', onDocumentKeyDownForDayMenu)
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function detachDayContextMenuListeners() {
|
|
625
|
+
window.removeEventListener('pointerdown', onDocumentPointerDownForDayMenu, true)
|
|
626
|
+
window.removeEventListener('keydown', onDocumentKeyDownForDayMenu)
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function onDayContextMenu(date, mouseEvent) {
|
|
630
|
+
if (isEventTarget(mouseEvent)) return
|
|
631
|
+
|
|
632
|
+
mouseEvent.preventDefault()
|
|
633
|
+
mouseEvent.stopPropagation()
|
|
634
|
+
|
|
635
|
+
dayContextMenuDate.value = startOfDay(date)
|
|
636
|
+
dayContextMenuOpen.value = true
|
|
637
|
+
dayContextMenuStyle.value = {
|
|
638
|
+
top: `${mouseEvent.clientY}px`,
|
|
639
|
+
left: `${mouseEvent.clientX}px`,
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
nextTick(() => {
|
|
643
|
+
positionDayContextMenu(mouseEvent.clientX, mouseEvent.clientY)
|
|
644
|
+
dayContextMenuRef.value?.querySelector('button')?.focus()
|
|
645
|
+
})
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function showOnlyThisWeek() {
|
|
649
|
+
const date = dayContextMenuDate.value
|
|
650
|
+
if (!date) return
|
|
651
|
+
|
|
652
|
+
commitViewChange('week', date)
|
|
653
|
+
closeDayContextMenu()
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function showOnlyThisDay() {
|
|
657
|
+
const date = dayContextMenuDate.value
|
|
658
|
+
if (!date) return
|
|
659
|
+
|
|
660
|
+
commitViewChange('day', date)
|
|
661
|
+
closeDayContextMenu()
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function showFullMonth() {
|
|
665
|
+
commitViewChange('month', currentDate.value)
|
|
666
|
+
closeDayContextMenu()
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function showWeekViewFromDay() {
|
|
670
|
+
commitViewChange('week', gridStartDate.value)
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function setView({ mode, focusDate: nextFocusDate } = {}) {
|
|
674
|
+
const nextMode = mode ?? internalViewMode.value
|
|
675
|
+
const anchor = nextFocusDate ?? (nextMode === 'month' ? currentDate.value : gridStartDate.value)
|
|
676
|
+
commitViewChange(nextMode, anchor)
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
defineExpose({
|
|
680
|
+
setView,
|
|
681
|
+
showMonthView: () => commitViewChange('month', currentDate.value),
|
|
682
|
+
showWeekView: (date) => commitViewChange('week', date ?? gridStartDate.value),
|
|
683
|
+
showDayView: (date) => commitViewChange('day', date ?? gridStartDate.value),
|
|
684
|
+
})
|
|
685
|
+
|
|
686
|
+
watch(dayContextMenuOpen, (open) => {
|
|
687
|
+
if (open) {
|
|
688
|
+
attachDayContextMenuListeners()
|
|
689
|
+
} else {
|
|
690
|
+
detachDayContextMenuListeners()
|
|
691
|
+
}
|
|
692
|
+
})
|
|
693
|
+
|
|
694
|
+
// --- Date range selection (click / drag) ---
|
|
695
|
+
|
|
374
696
|
function toDateKey(d) {
|
|
375
697
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
|
376
698
|
}
|
|
@@ -488,6 +810,7 @@ function onDayPointerEnter(date) {
|
|
|
488
810
|
|
|
489
811
|
onUnmounted(() => {
|
|
490
812
|
detachRangePointerListeners()
|
|
813
|
+
detachDayContextMenuListeners()
|
|
491
814
|
})
|
|
492
815
|
|
|
493
816
|
// --- Calendar event drag and drop (HTML5) ---
|
|
@@ -595,7 +918,7 @@ function onDayDrop(dayDate, ev) {
|
|
|
595
918
|
|
|
596
919
|
// Sync grid start when controlled month/year props change (but not during scrolling)
|
|
597
920
|
watch([viewMonth, viewYear], () => {
|
|
598
|
-
if (isScrolling.value) return
|
|
921
|
+
if (isScrolling.value || internalViewMode.value !== 'month') return
|
|
599
922
|
const anchor = new Date(viewYear.value, viewMonth.value, 1)
|
|
600
923
|
gridStartDate.value = getStartOfGrid(anchor)
|
|
601
924
|
})
|
|
@@ -605,7 +928,7 @@ watch([viewMonth, viewYear], () => {
|
|
|
605
928
|
<template>
|
|
606
929
|
<div class="calendar-wrapper">
|
|
607
930
|
<div v-if="showNavigation" class="calendar-header-nav">
|
|
608
|
-
<h2 class="calendar-title">{{
|
|
931
|
+
<h2 class="calendar-title">{{ headerTitle }}</h2>
|
|
609
932
|
<div class="calendar-nav-buttons">
|
|
610
933
|
<slot name="nav-buttons">
|
|
611
934
|
<button @click="previousMonth" class="button neutral">‹</button>
|
|
@@ -616,18 +939,40 @@ watch([viewMonth, viewYear], () => {
|
|
|
616
939
|
</div>
|
|
617
940
|
|
|
618
941
|
<div class="calendar-container">
|
|
942
|
+
<div v-if="isCompactView" class="calendar-compact-view-bar">
|
|
943
|
+
<button
|
|
944
|
+
v-if="internalViewMode === 'day'"
|
|
945
|
+
type="button"
|
|
946
|
+
class="button neutral"
|
|
947
|
+
@click="showWeekViewFromDay"
|
|
948
|
+
>
|
|
949
|
+
‹ Week view
|
|
950
|
+
</button>
|
|
951
|
+
<button type="button" class="button neutral" @click="showFullMonth">
|
|
952
|
+
‹ Month view
|
|
953
|
+
</button>
|
|
954
|
+
</div>
|
|
619
955
|
<div v-if="error" class="calendar-error">{{ error }}</div>
|
|
620
956
|
<div v-if="loading" class="calendar-loading-overlay">Loading…</div>
|
|
621
957
|
<div
|
|
622
958
|
class="calendar-grid"
|
|
623
959
|
:class="{
|
|
624
960
|
'is-dragging-range': isDraggingRange,
|
|
625
|
-
'is-dragging-event': draggingEventId !== null
|
|
961
|
+
'is-dragging-event': draggingEventId !== null,
|
|
962
|
+
'week-only': internalViewMode === 'week',
|
|
963
|
+
'day-only': internalViewMode === 'day'
|
|
626
964
|
}"
|
|
627
965
|
@wheel.prevent="handleWheel"
|
|
628
966
|
>
|
|
629
967
|
<!-- Day headers -->
|
|
630
|
-
<div
|
|
968
|
+
<div
|
|
969
|
+
v-for="day in dayNames"
|
|
970
|
+
v-show="internalViewMode !== 'day'"
|
|
971
|
+
:key="day"
|
|
972
|
+
class="day-header"
|
|
973
|
+
>
|
|
974
|
+
{{ day }}
|
|
975
|
+
</div>
|
|
631
976
|
|
|
632
977
|
<!-- Calendar days -->
|
|
633
978
|
<div
|
|
@@ -637,6 +982,7 @@ watch([viewMonth, viewYear], () => {
|
|
|
637
982
|
:data-calendar-date="toDateKey(day.date)"
|
|
638
983
|
@mousedown="onDayPointerDown(day.date, $event)"
|
|
639
984
|
@touchstart.passive="onDayPointerDown(day.date, $event)"
|
|
985
|
+
@contextmenu="onDayContextMenu(day.date, $event)"
|
|
640
986
|
@mouseenter="onDayPointerEnter(day.date)"
|
|
641
987
|
@dragover="onDayDragOver(day.date, $event)"
|
|
642
988
|
@drop="onDayDrop(day.date, $event)"
|
|
@@ -667,13 +1013,7 @@ watch([viewMonth, viewYear], () => {
|
|
|
667
1013
|
</div>
|
|
668
1014
|
<div class="day-events">
|
|
669
1015
|
<div
|
|
670
|
-
v-for="event in day
|
|
671
|
-
const aMultiDay = isMultiDayEvent(a);
|
|
672
|
-
const bMultiDay = isMultiDayEvent(b);
|
|
673
|
-
if (aMultiDay && !bMultiDay) return -1;
|
|
674
|
-
if (!aMultiDay && bMultiDay) return 1;
|
|
675
|
-
return 0;
|
|
676
|
-
}).slice(0, 3)"
|
|
1016
|
+
v-for="event in visibleEventsForDay(day)"
|
|
677
1017
|
:key="event.id"
|
|
678
1018
|
class="calendar-event"
|
|
679
1019
|
:class="{
|
|
@@ -714,7 +1054,10 @@ watch([viewMonth, viewYear], () => {
|
|
|
714
1054
|
</div>
|
|
715
1055
|
</slot>
|
|
716
1056
|
</div>
|
|
717
|
-
<div
|
|
1057
|
+
<div
|
|
1058
|
+
v-if="internalViewMode !== 'day' && day.events.length > 3"
|
|
1059
|
+
class="more-events"
|
|
1060
|
+
>
|
|
718
1061
|
+{{ day.events.length - 3 }} more
|
|
719
1062
|
</div>
|
|
720
1063
|
</div>
|
|
@@ -722,6 +1065,46 @@ watch([viewMonth, viewYear], () => {
|
|
|
722
1065
|
</div>
|
|
723
1066
|
</div>
|
|
724
1067
|
</div>
|
|
1068
|
+
|
|
1069
|
+
<Teleport to="body">
|
|
1070
|
+
<div
|
|
1071
|
+
v-if="dayContextMenuOpen"
|
|
1072
|
+
ref="dayContextMenuRef"
|
|
1073
|
+
class="calendar-day-context-menu"
|
|
1074
|
+
role="menu"
|
|
1075
|
+
:style="dayContextMenuStyle"
|
|
1076
|
+
@click.stop
|
|
1077
|
+
@contextmenu.prevent
|
|
1078
|
+
>
|
|
1079
|
+
<button
|
|
1080
|
+
v-if="internalViewMode !== 'day'"
|
|
1081
|
+
type="button"
|
|
1082
|
+
role="menuitem"
|
|
1083
|
+
class="calendar-day-context-menu-item"
|
|
1084
|
+
@click="showOnlyThisDay"
|
|
1085
|
+
>
|
|
1086
|
+
Show only this day
|
|
1087
|
+
</button>
|
|
1088
|
+
<button
|
|
1089
|
+
v-if="internalViewMode !== 'week'"
|
|
1090
|
+
type="button"
|
|
1091
|
+
role="menuitem"
|
|
1092
|
+
class="calendar-day-context-menu-item"
|
|
1093
|
+
@click="showOnlyThisWeek"
|
|
1094
|
+
>
|
|
1095
|
+
Show only this week
|
|
1096
|
+
</button>
|
|
1097
|
+
<button
|
|
1098
|
+
v-if="internalViewMode !== 'month'"
|
|
1099
|
+
type="button"
|
|
1100
|
+
role="menuitem"
|
|
1101
|
+
class="calendar-day-context-menu-item"
|
|
1102
|
+
@click="showFullMonth"
|
|
1103
|
+
>
|
|
1104
|
+
Show full month
|
|
1105
|
+
</button>
|
|
1106
|
+
</div>
|
|
1107
|
+
</Teleport>
|
|
725
1108
|
</div>
|
|
726
1109
|
</template>
|
|
727
1110
|
|
|
@@ -781,12 +1164,42 @@ watch([viewMonth, viewYear], () => {
|
|
|
781
1164
|
position: relative;
|
|
782
1165
|
}
|
|
783
1166
|
|
|
1167
|
+
.calendar-compact-view-bar {
|
|
1168
|
+
display: flex;
|
|
1169
|
+
align-items: center;
|
|
1170
|
+
gap: 0.5rem;
|
|
1171
|
+
padding: 0.5rem 0.75rem;
|
|
1172
|
+
border-bottom: 1px solid var(--calendar-border);
|
|
1173
|
+
background: var(--calendar-chrome-bg);
|
|
1174
|
+
}
|
|
1175
|
+
|
|
784
1176
|
.calendar-grid {
|
|
785
1177
|
display: grid;
|
|
786
1178
|
grid-template-columns: repeat(7, minmax(0, 1fr));
|
|
787
1179
|
min-height: 400px;
|
|
788
1180
|
}
|
|
789
1181
|
|
|
1182
|
+
.calendar-grid.week-only {
|
|
1183
|
+
min-height: min(72vh, 760px);
|
|
1184
|
+
grid-template-rows: auto 1fr;
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
.calendar-grid.week-only .calendar-day {
|
|
1188
|
+
height: auto;
|
|
1189
|
+
min-height: min(68vh, 720px);
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
.calendar-grid.day-only {
|
|
1193
|
+
grid-template-columns: minmax(0, 1fr);
|
|
1194
|
+
min-height: min(72vh, 760px);
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
.calendar-grid.day-only .calendar-day {
|
|
1198
|
+
height: auto;
|
|
1199
|
+
min-height: min(68vh, 720px);
|
|
1200
|
+
border-right: none;
|
|
1201
|
+
}
|
|
1202
|
+
|
|
790
1203
|
.calendar-grid.is-dragging-range,
|
|
791
1204
|
.calendar-grid.is-dragging-event {
|
|
792
1205
|
user-select: none;
|
|
@@ -1068,6 +1481,12 @@ watch([viewMonth, viewYear], () => {
|
|
|
1068
1481
|
height: 110px;
|
|
1069
1482
|
}
|
|
1070
1483
|
|
|
1484
|
+
.calendar-grid.week-only .calendar-day,
|
|
1485
|
+
.calendar-grid.day-only .calendar-day {
|
|
1486
|
+
height: auto;
|
|
1487
|
+
min-height: min(52vh, 420px);
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1071
1490
|
.day-number {
|
|
1072
1491
|
font-size: 1rem;
|
|
1073
1492
|
}
|
|
@@ -1103,3 +1522,35 @@ watch([viewMonth, viewYear], () => {
|
|
|
1103
1522
|
}
|
|
1104
1523
|
}
|
|
1105
1524
|
</style>
|
|
1525
|
+
|
|
1526
|
+
<style>
|
|
1527
|
+
.calendar-day-context-menu {
|
|
1528
|
+
position: fixed;
|
|
1529
|
+
z-index: 1000;
|
|
1530
|
+
min-width: 12rem;
|
|
1531
|
+
padding: 0.35rem;
|
|
1532
|
+
border: 1px solid var(--table-popover-border);
|
|
1533
|
+
border-radius: 8px;
|
|
1534
|
+
background: var(--table-popover-bg);
|
|
1535
|
+
box-shadow: var(--table-popover-shadow);
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
.calendar-day-context-menu-item {
|
|
1539
|
+
display: block;
|
|
1540
|
+
width: 100%;
|
|
1541
|
+
padding: 0.5rem 0.75rem;
|
|
1542
|
+
border: none;
|
|
1543
|
+
border-radius: 4px;
|
|
1544
|
+
background: transparent;
|
|
1545
|
+
color: var(--table-popover-fg);
|
|
1546
|
+
font: inherit;
|
|
1547
|
+
text-align: left;
|
|
1548
|
+
cursor: pointer;
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
.calendar-day-context-menu-item:hover,
|
|
1552
|
+
.calendar-day-context-menu-item:focus-visible {
|
|
1553
|
+
background: var(--table-popover-item-bg);
|
|
1554
|
+
outline: none;
|
|
1555
|
+
}
|
|
1556
|
+
</style>
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<Section
|
|
3
|
-
title="Example Calendar"
|
|
4
|
-
|
|
2
|
+
<Section
|
|
3
|
+
title="Example Calendar"
|
|
4
|
+
subtitle="Open with ?view=week&date=2026-09-08 or ?view=day&date=2026-09-13 to load a specific week or day."
|
|
5
|
+
:icon="Calendar01Icon"
|
|
6
|
+
:padding="sectionPadding"
|
|
5
7
|
>
|
|
6
8
|
<template #toolbar>
|
|
7
|
-
<label class="
|
|
9
|
+
<label class="calendar-example-toggle">
|
|
10
|
+
<input type="checkbox" v-model="sectionPadding" />
|
|
11
|
+
Section padding
|
|
12
|
+
</label>
|
|
13
|
+
<label class="calendar-example-toggle">
|
|
8
14
|
<input type="checkbox" v-model="shortMonthSuffix" />
|
|
9
15
|
Short month suffix
|
|
10
16
|
</label>
|
|
@@ -21,8 +27,7 @@
|
|
|
21
27
|
</div>
|
|
22
28
|
<button @click="nextMonth">›</button>
|
|
23
29
|
</template>
|
|
24
|
-
<Calendar
|
|
25
|
-
:section="true"
|
|
30
|
+
<Calendar
|
|
26
31
|
:events="events"
|
|
27
32
|
:loading="loading"
|
|
28
33
|
:error="error"
|
|
@@ -30,6 +35,8 @@
|
|
|
30
35
|
:short-month-suffix="shortMonthSuffix"
|
|
31
36
|
:current-month="currentMonthIndex"
|
|
32
37
|
:current-year="currentYear"
|
|
38
|
+
v-model:view-mode="calendarViewMode"
|
|
39
|
+
v-model:focus-date="calendarFocusDate"
|
|
33
40
|
@event-click="handleEventClick"
|
|
34
41
|
@date-click="handleDateClick"
|
|
35
42
|
@date-range-select="handleDateRangeSelect"
|
|
@@ -37,12 +44,14 @@
|
|
|
37
44
|
@event-moved="handleEventMoved"
|
|
38
45
|
@event-move-rejected="handleEventMoveRejected"
|
|
39
46
|
@month-change="handleMonthChange"
|
|
47
|
+
@view-change="handleViewChange"
|
|
40
48
|
/>
|
|
41
49
|
</Section>
|
|
42
50
|
</template>
|
|
43
51
|
|
|
44
52
|
<script setup>
|
|
45
53
|
import { ref, onMounted } from 'vue'
|
|
54
|
+
import { useRoute, useRouter } from 'vue-router'
|
|
46
55
|
import Calendar from '../components/Calendar.vue'
|
|
47
56
|
import Section from '../components/Section.vue'
|
|
48
57
|
import {
|
|
@@ -67,10 +76,38 @@ const EVENT_COLORS = {
|
|
|
67
76
|
deadline: { color: '#fde8e8', foregroundColor: '#7f1d1d' },
|
|
68
77
|
}
|
|
69
78
|
|
|
79
|
+
const route = useRoute()
|
|
80
|
+
const router = useRouter()
|
|
81
|
+
|
|
82
|
+
const VALID_VIEW_MODES = new Set(['month', 'week', 'day'])
|
|
83
|
+
|
|
84
|
+
function parseRouteViewMode() {
|
|
85
|
+
const view = route.query.view
|
|
86
|
+
return typeof view === 'string' && VALID_VIEW_MODES.has(view) ? view : 'month'
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function parseRouteFocusDate() {
|
|
90
|
+
const date = route.query.date
|
|
91
|
+
if (typeof date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
|
92
|
+
return null
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const parsed = new Date(`${date}T00:00:00`)
|
|
96
|
+
return Number.isNaN(parsed.getTime()) ? null : parsed
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function formatFocusDateQuery(dateValue) {
|
|
100
|
+
const date = new Date(dateValue)
|
|
101
|
+
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
|
102
|
+
}
|
|
103
|
+
|
|
70
104
|
const events = ref([])
|
|
71
105
|
const loading = ref(false)
|
|
72
106
|
const error = ref(null)
|
|
107
|
+
const sectionPadding = ref(true)
|
|
73
108
|
const shortMonthSuffix = ref(false)
|
|
109
|
+
const calendarViewMode = ref(parseRouteViewMode())
|
|
110
|
+
const calendarFocusDate = ref(parseRouteFocusDate())
|
|
74
111
|
const currentMonth = ref('?')
|
|
75
112
|
const currentMonthIndex = ref(new Date().getMonth())
|
|
76
113
|
const currentYear = ref(new Date().getFullYear())
|
|
@@ -368,6 +405,22 @@ function goToSelectedDate() {
|
|
|
368
405
|
events.value = getEventsForSurroundingMonths(monthIndex, year)
|
|
369
406
|
}
|
|
370
407
|
|
|
408
|
+
function handleViewChange({ mode, focusDate }) {
|
|
409
|
+
const query = { ...route.query }
|
|
410
|
+
|
|
411
|
+
if (mode === 'month') {
|
|
412
|
+
delete query.view
|
|
413
|
+
delete query.date
|
|
414
|
+
} else {
|
|
415
|
+
query.view = mode
|
|
416
|
+
if (focusDate) {
|
|
417
|
+
query.date = formatFocusDateQuery(focusDate)
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
router.replace({ query })
|
|
422
|
+
}
|
|
423
|
+
|
|
371
424
|
function handleMonthChange(month, year) {
|
|
372
425
|
console.log('Month changed:', month, year)
|
|
373
426
|
// In a real app, you might fetch events for the new month
|
|
@@ -395,29 +448,24 @@ function handleMonthChange(month, year) {
|
|
|
395
448
|
}
|
|
396
449
|
|
|
397
450
|
onMounted(() => {
|
|
398
|
-
|
|
399
|
-
const
|
|
400
|
-
const
|
|
401
|
-
|
|
451
|
+
const initialAnchor = calendarFocusDate.value ?? new Date()
|
|
452
|
+
const initMonth = initialAnchor.getMonth()
|
|
453
|
+
const initYear = initialAnchor.getFullYear()
|
|
454
|
+
|
|
402
455
|
events.value = getEventsForSurroundingMonths(initMonth, initYear)
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
month: 'long',
|
|
409
|
-
year: 'numeric'
|
|
456
|
+
currentMonthIndex.value = initMonth
|
|
457
|
+
currentYear.value = initYear
|
|
458
|
+
currentMonth.value = initialAnchor.toLocaleDateString('en-US', {
|
|
459
|
+
month: 'long',
|
|
460
|
+
year: 'numeric',
|
|
410
461
|
})
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
const year = now.getFullYear()
|
|
414
|
-
const month = String(now.getMonth() + 1).padStart(2, '0')
|
|
415
|
-
selectedDate.value = `${year}-${month}`
|
|
462
|
+
|
|
463
|
+
updateDatePicker(initMonth, initYear)
|
|
416
464
|
})
|
|
417
465
|
</script>
|
|
418
466
|
|
|
419
467
|
<style scoped>
|
|
420
|
-
.
|
|
468
|
+
.calendar-example-toggle {
|
|
421
469
|
display: inline-flex;
|
|
422
470
|
align-items: center;
|
|
423
471
|
gap: 0.35rem;
|