picocrank 1.14.1 → 1.16.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/logo.png +0 -0
- package/logo.svg +10 -10
- package/package.json +7 -7
- package/vue/components/Calendar.vue +369 -14
- package/vue/components/Header.vue +10 -2
- package/vue/views/App.vue +9 -2
- package/vue/views/CalendarExample.vue +48 -1
- package/vue/views/Welcome.vue +6 -1
package/logo.png
CHANGED
|
Binary file
|
package/logo.svg
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
2
2
|
<svg
|
|
3
|
-
width="
|
|
4
|
-
height="
|
|
5
|
-
viewBox="0 0
|
|
3
|
+
width="512"
|
|
4
|
+
height="512"
|
|
5
|
+
viewBox="0 0 512 512"
|
|
6
6
|
version="1.1"
|
|
7
7
|
id="svg2"
|
|
8
8
|
sodipodi:docname="logo.svg"
|
|
@@ -25,9 +25,9 @@
|
|
|
25
25
|
inkscape:pageopacity="0"
|
|
26
26
|
inkscape:pagecheckerboard="1"
|
|
27
27
|
inkscape:deskcolor="#d1d1d1"
|
|
28
|
-
inkscape:zoom="
|
|
29
|
-
inkscape:cx="
|
|
30
|
-
inkscape:cy="
|
|
28
|
+
inkscape:zoom="2"
|
|
29
|
+
inkscape:cx="114"
|
|
30
|
+
inkscape:cy="158.25"
|
|
31
31
|
inkscape:window-width="3840"
|
|
32
32
|
inkscape:window-height="2126"
|
|
33
33
|
inkscape:window-x="2160"
|
|
@@ -42,12 +42,12 @@
|
|
|
42
42
|
stroke-width="1.5"
|
|
43
43
|
id="g2">
|
|
44
44
|
<path
|
|
45
|
-
d="
|
|
45
|
+
d="m 447.76523,291.03986 c 10.92171,-2.97926 16.38257,-4.47944 18.53762,-7.31078 2.15507,-2.85247 2.15507,-7.41641 2.15507,-16.56544 v -40.86426 c 0,-9.14903 0,-13.73411 -2.15507,-16.56545 -2.15505,-2.83134 -7.61591,-4.33152 -18.53762,-7.28964 -40.79956,-11.11407 -66.34635,-54.1969 -55.82216,-95.14567 2.90828,-11.26197 4.35196,-16.903525 2.97105,-20.199705 -1.38091,-3.296193 -5.35624,-5.578171 -13.286,-10.120992 L 345.53621,56.271118 c -7.78328,-4.437171 -11.67494,-6.676887 -15.16906,-6.212043 -3.49411,0.464844 -7.44851,4.437171 -15.33643,12.402959 -30.52643,30.743272 -81.03417,30.743272 -111.58152,0 -7.8879,-7.944653 -11.8214,-11.916978 -15.31552,-12.402959 -3.49411,-0.464844 -7.38576,1.753736 -15.16906,6.233169 l -36.0919,20.685679 c -7.95067,4.542821 -11.92602,6.824799 -13.286,10.142117 -1.38091,3.275055 0.0628,8.91661 2.95012,20.17858 10.52418,40.94877 -15.0226,84.0316 -55.84307,95.14567 -10.92172,2.95812 -16.382581,4.43717 -18.537633,7.28964 -2.155053,2.85246 -2.155053,7.41642 -2.155053,16.56545 v 40.86426 c 0,9.14903 0,13.7341 2.155053,16.56544 2.155052,2.83134 7.615913,4.33152 18.537633,7.31078 40.79955,11.11405 66.34634,54.1969 55.82215,95.12453 -2.90828,11.26197 -4.35195,16.90352 -2.97105,20.19971 1.38091,3.29619 5.35625,5.57816 13.28601,10.1421 l 36.0919,20.66456 c 7.78331,4.47943 11.67494,6.69802 15.16906,6.23317 3.49411,-0.46485 7.44854,-4.43717 15.33644,-12.40295 30.54735,-30.78553 81.09693,-30.78553 111.64428,0 7.88791,7.94466 11.8214,11.91698 15.31552,12.40295 3.49411,0.46485 7.38577,-1.75374 15.18998,-6.23317 l 36.07098,-20.68568 c 7.95069,-4.54282 11.92602,-6.82479 13.28599,-10.14212 1.36,-3.31731 -0.0627,-8.9166 -2.95011,-20.17857 -10.54511,-40.92763 14.98075,-84.01048 55.78031,-95.12453"
|
|
46
46
|
id="path2"
|
|
47
|
-
style="fill:#42b883;fill-opacity:1" />
|
|
47
|
+
style="fill:#42b883;fill-opacity:1;stroke-width:20;stroke-dasharray:none" />
|
|
48
48
|
<path
|
|
49
|
-
d="m
|
|
49
|
+
d="m 338.03417,247.36355 a 74.66655,74.66655 0 1 1 -149.3331,0 74.66655,74.66655 0 0 1 149.3331,0"
|
|
50
50
|
id="path1"
|
|
51
|
-
style="fill:#35495e;fill-opacity:1" />
|
|
51
|
+
style="fill:#35495e;fill-opacity:1;stroke-width:20;stroke-dasharray:none" />
|
|
52
52
|
</g>
|
|
53
53
|
</svg>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "picocrank",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.16.0",
|
|
4
4
|
"description": "A set of zero dependency Vue components built on the Femtocrank global CSS theme.",
|
|
5
5
|
"main": "vite.config.js",
|
|
6
6
|
"scripts": {
|
|
@@ -18,14 +18,14 @@
|
|
|
18
18
|
"url": "https://github.com/jamesread/picocrank/issues"
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"@hugeicons/core-free-icons": "^4.
|
|
21
|
+
"@hugeicons/core-free-icons": "^4.1.1",
|
|
22
22
|
"@hugeicons/vue": "^1.0.5",
|
|
23
|
-
"@vitejs/plugin-vue": "^6.0.
|
|
23
|
+
"@vitejs/plugin-vue": "^6.0.6",
|
|
24
24
|
"femtocrank": "^2.5.0",
|
|
25
|
-
"unplugin-vue-components": "^
|
|
26
|
-
"vite": "^
|
|
27
|
-
"vue": "^3.5.
|
|
28
|
-
"vue-router": "^5.0.
|
|
25
|
+
"unplugin-vue-components": "^32.0.0",
|
|
26
|
+
"vite": "^8.0.10",
|
|
27
|
+
"vue": "^3.5.33",
|
|
28
|
+
"vue-router": "^5.0.6"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
31
|
"semantic-release": "^25.0.3"
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { ref, computed } from 'vue'
|
|
2
|
+
import { ref, computed, watch, onUnmounted } from 'vue'
|
|
3
3
|
|
|
4
4
|
export interface CalendarEvent {
|
|
5
5
|
id: string | number
|
|
@@ -10,12 +10,31 @@ export interface CalendarEvent {
|
|
|
10
10
|
[key: string]: any
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
/** Payload for `event-move-request`: call `respond(true)` after a successful commit, or `respond(false)` to decline (e.g. failed server update). */
|
|
14
|
+
export interface CalendarEventMoveRequest {
|
|
15
|
+
event: CalendarEvent
|
|
16
|
+
/** Calendar cell where the drag started (for multi-day events, the visible segment day). */
|
|
17
|
+
sourceDate: Date
|
|
18
|
+
/** Calendar cell where the event was dropped. */
|
|
19
|
+
targetDate: Date
|
|
20
|
+
/** Invoke exactly once with the outcome. If omitted until timeout, the move is treated as rejected. */
|
|
21
|
+
respond: (accepted: boolean) => void
|
|
22
|
+
}
|
|
23
|
+
|
|
13
24
|
export interface CalendarProps {
|
|
14
25
|
events: CalendarEvent[]
|
|
15
26
|
monthNames?: string[]
|
|
16
27
|
dayNames?: string[]
|
|
17
28
|
loading?: boolean
|
|
18
29
|
error?: string | null
|
|
30
|
+
/** When true, events can be dragged to another day. */
|
|
31
|
+
eventDragEnabled?: boolean
|
|
32
|
+
/**
|
|
33
|
+
* Max time (ms) to wait for `respond()` on `event-move-request`.
|
|
34
|
+
* If `respond` is not called in time, the move is rejected and a warning is logged.
|
|
35
|
+
* Set to `0` to disable the timeout (not recommended if listeners may omit `respond`).
|
|
36
|
+
*/
|
|
37
|
+
eventMoveResponseTimeoutMs?: number
|
|
19
38
|
// Formatter functions
|
|
20
39
|
getEventDate?: (event: CalendarEvent) => Date | null
|
|
21
40
|
getEventDateRange?: (event: CalendarEvent) => { start: Date | null; end: Date | null }
|
|
@@ -35,14 +54,20 @@ const props = withDefaults(defineProps<CalendarProps>(), {
|
|
|
35
54
|
dayNames: () => ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
|
|
36
55
|
loading: false,
|
|
37
56
|
error: null,
|
|
57
|
+
eventDragEnabled: true,
|
|
58
|
+
eventMoveResponseTimeoutMs: 15000,
|
|
38
59
|
showNavigation: true,
|
|
39
60
|
})
|
|
40
61
|
|
|
41
62
|
const emit = defineEmits<{
|
|
42
63
|
'event-click': [event: CalendarEvent]
|
|
43
64
|
'date-click': [date: Date]
|
|
65
|
+
'date-range-select': [start: Date, end: Date]
|
|
44
66
|
'month-change': [month: number, year: number]
|
|
45
67
|
'event-context-menu': [event: CalendarEvent, mouseEvent: MouseEvent]
|
|
68
|
+
'event-move-request': [payload: CalendarEventMoveRequest]
|
|
69
|
+
'event-moved': [payload: { event: CalendarEvent; sourceDate: Date; targetDate: Date }]
|
|
70
|
+
'event-move-rejected': [payload: { event: CalendarEvent; sourceDate: Date; targetDate: Date }]
|
|
46
71
|
}>()
|
|
47
72
|
|
|
48
73
|
// Internal calendar state
|
|
@@ -316,16 +341,239 @@ function handleEventClick(event: CalendarEvent) {
|
|
|
316
341
|
emit('event-click', event)
|
|
317
342
|
}
|
|
318
343
|
|
|
319
|
-
function handleDateClick(date: Date) {
|
|
320
|
-
emit('date-click', date)
|
|
321
|
-
}
|
|
322
|
-
|
|
323
344
|
function handleContextMenu(event: CalendarEvent, mouseEvent: MouseEvent) {
|
|
324
345
|
mouseEvent.preventDefault()
|
|
325
346
|
mouseEvent.stopPropagation()
|
|
326
347
|
emit('event-context-menu', event, mouseEvent)
|
|
327
348
|
}
|
|
328
|
-
|
|
349
|
+
|
|
350
|
+
// --- Date range selection (click / drag) ---
|
|
351
|
+
|
|
352
|
+
function startOfDay(d: Date): Date {
|
|
353
|
+
const x = new Date(d)
|
|
354
|
+
x.setHours(0, 0, 0, 0)
|
|
355
|
+
return x
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function toDateKey(d: Date): string {
|
|
359
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function dateFromKey(key: string): Date {
|
|
363
|
+
const [y, m, day] = key.split('-').map(Number)
|
|
364
|
+
return startOfDay(new Date(y, m - 1, day))
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const isDraggingRange = ref(false)
|
|
368
|
+
const rangeDragAnchor = ref<Date | null>(null)
|
|
369
|
+
const rangeDragEnd = ref<Date | null>(null)
|
|
370
|
+
const selectedRangeStart = ref<Date | null>(null)
|
|
371
|
+
const selectedRangeEnd = ref<Date | null>(null)
|
|
372
|
+
|
|
373
|
+
function isEventTarget(ev: Event): boolean {
|
|
374
|
+
const t = ev.target
|
|
375
|
+
if (!(t instanceof Node)) return false
|
|
376
|
+
return Boolean((t as HTMLElement).closest?.('.calendar-event'))
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function getRangeBounds(): { lo: Date; hi: Date } | null {
|
|
380
|
+
const a = rangeDragAnchor.value
|
|
381
|
+
const b = rangeDragEnd.value
|
|
382
|
+
if (!a || !b) return null
|
|
383
|
+
const sa = startOfDay(a).getTime()
|
|
384
|
+
const sb = startOfDay(b).getTime()
|
|
385
|
+
if (sa <= sb) return { lo: startOfDay(a), hi: startOfDay(b) }
|
|
386
|
+
return { lo: startOfDay(b), hi: startOfDay(a) }
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function rangeHighlightForDate(date: Date): 'none' | 'single' | 'start' | 'end' | 'middle' {
|
|
390
|
+
const bounds = isDraggingRange.value
|
|
391
|
+
? getRangeBounds()
|
|
392
|
+
: selectedRangeStart.value && selectedRangeEnd.value
|
|
393
|
+
? (() => {
|
|
394
|
+
const sa = startOfDay(selectedRangeStart.value!).getTime()
|
|
395
|
+
const sb = startOfDay(selectedRangeEnd.value!).getTime()
|
|
396
|
+
const lo = sa <= sb ? startOfDay(selectedRangeStart.value!) : startOfDay(selectedRangeEnd.value!)
|
|
397
|
+
const hi = sa <= sb ? startOfDay(selectedRangeEnd.value!) : startOfDay(selectedRangeStart.value!)
|
|
398
|
+
return { lo, hi }
|
|
399
|
+
})()
|
|
400
|
+
: null
|
|
401
|
+
if (!bounds) return 'none'
|
|
402
|
+
const t = startOfDay(date).getTime()
|
|
403
|
+
if (t < bounds.lo.getTime() || t > bounds.hi.getTime()) return 'none'
|
|
404
|
+
if (bounds.lo.getTime() === bounds.hi.getTime()) return 'single'
|
|
405
|
+
if (t === bounds.lo.getTime()) return 'start'
|
|
406
|
+
if (t === bounds.hi.getTime()) return 'end'
|
|
407
|
+
return 'middle'
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function onWindowTouchMove(ev: TouchEvent): void {
|
|
411
|
+
if (!isDraggingRange.value || ev.touches.length === 0) return
|
|
412
|
+
const { clientX, clientY } = ev.touches[0]
|
|
413
|
+
const el = document.elementFromPoint(clientX, clientY)
|
|
414
|
+
if (!el) return
|
|
415
|
+
const cell = (el as HTMLElement).closest?.('[data-calendar-date]')
|
|
416
|
+
const key = cell?.dataset?.calendarDate
|
|
417
|
+
if (!key) return
|
|
418
|
+
rangeDragEnd.value = dateFromKey(key)
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function detachRangePointerListeners(): void {
|
|
422
|
+
window.removeEventListener('mouseup', endRangeSelectionFromPointer)
|
|
423
|
+
window.removeEventListener('blur', endRangeSelectionFromPointer)
|
|
424
|
+
window.removeEventListener('touchmove', onWindowTouchMove)
|
|
425
|
+
window.removeEventListener('touchend', endRangeSelectionFromPointer)
|
|
426
|
+
window.removeEventListener('touchcancel', endRangeSelectionFromPointer)
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function endRangeSelectionFromPointer(): void {
|
|
430
|
+
if (!isDraggingRange.value) return
|
|
431
|
+
const bounds = getRangeBounds()
|
|
432
|
+
isDraggingRange.value = false
|
|
433
|
+
rangeDragAnchor.value = null
|
|
434
|
+
rangeDragEnd.value = null
|
|
435
|
+
detachRangePointerListeners()
|
|
436
|
+
|
|
437
|
+
if (!bounds) return
|
|
438
|
+
|
|
439
|
+
selectedRangeStart.value = new Date(bounds.lo)
|
|
440
|
+
selectedRangeEnd.value = new Date(bounds.hi)
|
|
441
|
+
emit('date-range-select', new Date(bounds.lo), new Date(bounds.hi))
|
|
442
|
+
|
|
443
|
+
if (bounds.lo.getTime() === bounds.hi.getTime()) {
|
|
444
|
+
emit('date-click', new Date(bounds.lo))
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function onDayPointerDown(date: Date, ev: MouseEvent | TouchEvent): void {
|
|
449
|
+
if (isEventTarget(ev)) return
|
|
450
|
+
if (ev instanceof MouseEvent && ev.button !== 0) return
|
|
451
|
+
|
|
452
|
+
const d = startOfDay(date)
|
|
453
|
+
isDraggingRange.value = true
|
|
454
|
+
rangeDragAnchor.value = new Date(d)
|
|
455
|
+
rangeDragEnd.value = new Date(d)
|
|
456
|
+
window.addEventListener('mouseup', endRangeSelectionFromPointer)
|
|
457
|
+
window.addEventListener('blur', endRangeSelectionFromPointer)
|
|
458
|
+
window.addEventListener('touchmove', onWindowTouchMove, { passive: true })
|
|
459
|
+
window.addEventListener('touchend', endRangeSelectionFromPointer)
|
|
460
|
+
window.addEventListener('touchcancel', endRangeSelectionFromPointer)
|
|
461
|
+
if (ev instanceof MouseEvent) {
|
|
462
|
+
ev.preventDefault()
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function onDayPointerEnter(date: Date): void {
|
|
467
|
+
if (!isDraggingRange.value) return
|
|
468
|
+
rangeDragEnd.value = startOfDay(date)
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
onUnmounted(() => {
|
|
472
|
+
detachRangePointerListeners()
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
// --- Calendar event drag and drop (HTML5) ---
|
|
476
|
+
|
|
477
|
+
const CALENDAR_EVENT_DRAG_MIME = 'application/x-picocrank-calendar-event-id'
|
|
478
|
+
|
|
479
|
+
const draggingEventId = ref<string | number | null>(null)
|
|
480
|
+
const draggingSourceDate = ref<Date | null>(null)
|
|
481
|
+
const dragOverTargetDate = ref<Date | null>(null)
|
|
482
|
+
const eventMoveLocked = ref(false)
|
|
483
|
+
|
|
484
|
+
function sameCalendarDay(a: Date | null, b: Date | null): boolean {
|
|
485
|
+
if (!a || !b) return false
|
|
486
|
+
return startOfDay(a).getTime() === startOfDay(b).getTime()
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function onEventDragStart(event: CalendarEvent, sourceCellDate: Date, dragEvent: DragEvent): void {
|
|
490
|
+
if (!props.eventDragEnabled) {
|
|
491
|
+
dragEvent.preventDefault()
|
|
492
|
+
return
|
|
493
|
+
}
|
|
494
|
+
draggingEventId.value = event.id
|
|
495
|
+
draggingSourceDate.value = startOfDay(sourceCellDate)
|
|
496
|
+
dragEvent.dataTransfer?.setData(CALENDAR_EVENT_DRAG_MIME, String(event.id))
|
|
497
|
+
dragEvent.dataTransfer?.setData('text/plain', event.title)
|
|
498
|
+
if (dragEvent.dataTransfer) {
|
|
499
|
+
dragEvent.dataTransfer.effectAllowed = 'move'
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function onEventDragEnd(): void {
|
|
504
|
+
draggingEventId.value = null
|
|
505
|
+
draggingSourceDate.value = null
|
|
506
|
+
dragOverTargetDate.value = null
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function onDayDragOver(dayDate: Date, ev: DragEvent): void {
|
|
510
|
+
if (!props.eventDragEnabled || draggingEventId.value === null) return
|
|
511
|
+
ev.preventDefault()
|
|
512
|
+
if (ev.dataTransfer) {
|
|
513
|
+
ev.dataTransfer.dropEffect = 'move'
|
|
514
|
+
}
|
|
515
|
+
dragOverTargetDate.value = startOfDay(dayDate)
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function onDayDrop(dayDate: Date, ev: DragEvent): void {
|
|
519
|
+
ev.preventDefault()
|
|
520
|
+
dragOverTargetDate.value = null
|
|
521
|
+
if (!props.eventDragEnabled) return
|
|
522
|
+
|
|
523
|
+
const idStr = ev.dataTransfer?.getData(CALENDAR_EVENT_DRAG_MIME)
|
|
524
|
+
if (!idStr) return
|
|
525
|
+
|
|
526
|
+
const moved = props.events.find(e => String(e.id) === idStr)
|
|
527
|
+
if (!moved) return
|
|
528
|
+
|
|
529
|
+
const sourceRaw = draggingSourceDate.value
|
|
530
|
+
if (!sourceRaw) return
|
|
531
|
+
|
|
532
|
+
const targetDate = startOfDay(dayDate)
|
|
533
|
+
const sourceDate = startOfDay(sourceRaw)
|
|
534
|
+
if (targetDate.getTime() === sourceDate.getTime()) return
|
|
535
|
+
if (eventMoveLocked.value) return
|
|
536
|
+
eventMoveLocked.value = true
|
|
537
|
+
|
|
538
|
+
const sourceClone = new Date(sourceDate)
|
|
539
|
+
const targetClone = new Date(targetDate)
|
|
540
|
+
|
|
541
|
+
let settled = false
|
|
542
|
+
let watchdog: ReturnType<typeof setTimeout> | undefined
|
|
543
|
+
|
|
544
|
+
function respond(accepted: boolean): void {
|
|
545
|
+
if (settled) return
|
|
546
|
+
settled = true
|
|
547
|
+
if (watchdog !== undefined) {
|
|
548
|
+
clearTimeout(watchdog)
|
|
549
|
+
}
|
|
550
|
+
eventMoveLocked.value = false
|
|
551
|
+
if (accepted) {
|
|
552
|
+
emit('event-moved', { event: moved, sourceDate: sourceClone, targetDate: targetClone })
|
|
553
|
+
} else {
|
|
554
|
+
emit('event-move-rejected', { event: moved, sourceDate: sourceClone, targetDate: targetClone })
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
emit('event-move-request', {
|
|
559
|
+
event: moved,
|
|
560
|
+
sourceDate: sourceClone,
|
|
561
|
+
targetDate: targetClone,
|
|
562
|
+
respond,
|
|
563
|
+
})
|
|
564
|
+
|
|
565
|
+
const timeoutMs = props.eventMoveResponseTimeoutMs
|
|
566
|
+
if (timeoutMs > 0) {
|
|
567
|
+
watchdog = setTimeout(() => {
|
|
568
|
+
if (!settled) {
|
|
569
|
+
console.warn(
|
|
570
|
+
'[Calendar] event-move-request: respond() was not called before timeout; treating as rejected.'
|
|
571
|
+
)
|
|
572
|
+
respond(false)
|
|
573
|
+
}
|
|
574
|
+
}, timeoutMs)
|
|
575
|
+
}
|
|
576
|
+
}
|
|
329
577
|
|
|
330
578
|
// Sync grid start when controlled month/year props change (but not during scrolling)
|
|
331
579
|
watch([currentMonth, currentYear], () => {
|
|
@@ -352,7 +600,14 @@ watch([currentMonth, currentYear], () => {
|
|
|
352
600
|
<div class="calendar-container">
|
|
353
601
|
<div v-if="error" class="calendar-error">{{ error }}</div>
|
|
354
602
|
<div v-if="loading" class="calendar-loading-overlay">Loading…</div>
|
|
355
|
-
<div
|
|
603
|
+
<div
|
|
604
|
+
class="calendar-grid"
|
|
605
|
+
:class="{
|
|
606
|
+
'is-dragging-range': isDraggingRange,
|
|
607
|
+
'is-dragging-event': draggingEventId !== null
|
|
608
|
+
}"
|
|
609
|
+
@wheel.prevent="handleWheel"
|
|
610
|
+
>
|
|
356
611
|
<!-- Day headers -->
|
|
357
612
|
<div v-for="day in dayNames" :key="day" class="day-header">{{ day }}</div>
|
|
358
613
|
|
|
@@ -361,15 +616,26 @@ watch([currentMonth, currentYear], () => {
|
|
|
361
616
|
v-for="(day, index) in calendarDays"
|
|
362
617
|
:key="index"
|
|
363
618
|
class="calendar-day"
|
|
619
|
+
:data-calendar-date="toDateKey(day.date)"
|
|
620
|
+
@mousedown="onDayPointerDown(day.date, $event)"
|
|
621
|
+
@touchstart.passive="onDayPointerDown(day.date, $event)"
|
|
622
|
+
@mouseenter="onDayPointerEnter(day.date)"
|
|
623
|
+
@dragover="onDayDragOver(day.date, $event)"
|
|
624
|
+
@drop="onDayDrop(day.date, $event)"
|
|
364
625
|
:class="{
|
|
365
|
-
'today': day
|
|
366
|
-
'weekend': day
|
|
367
|
-
'prev-month': day
|
|
368
|
-
'next-month': day
|
|
369
|
-
'past':
|
|
626
|
+
'today': day.date.toDateString() === new Date().toDateString(),
|
|
627
|
+
'weekend': day.date.getDay() === 0 || day.date.getDay() === 6,
|
|
628
|
+
'prev-month': day.date.getMonth() !== currentMonth && day.date.getMonth() !== (currentMonth + 1) % 12,
|
|
629
|
+
'next-month': day.date.getMonth() !== currentMonth && day.date.getMonth() === (currentMonth + 1) % 12,
|
|
630
|
+
'past': isPastDay(day.date),
|
|
631
|
+
'range-single': rangeHighlightForDate(day.date) === 'single',
|
|
632
|
+
'range-start': rangeHighlightForDate(day.date) === 'start',
|
|
633
|
+
'range-end': rangeHighlightForDate(day.date) === 'end',
|
|
634
|
+
'range-middle': rangeHighlightForDate(day.date) === 'middle',
|
|
635
|
+
'drag-over-target': eventDragEnabled && sameCalendarDay(dragOverTargetDate, day.date)
|
|
370
636
|
}"
|
|
371
637
|
>
|
|
372
|
-
<div
|
|
638
|
+
<div class="day-content">
|
|
373
639
|
<div class="day-number clickable">
|
|
374
640
|
{{ day.date.getDate() }}<span class="day-month">{{ getOrdinalSuffix(day.date.getDate()) }} {{ monthNames[day.date.getMonth()].substring(0, 3) }}</span>
|
|
375
641
|
</div>
|
|
@@ -388,8 +654,12 @@ watch([currentMonth, currentYear], () => {
|
|
|
388
654
|
'multi-day': isMultiDayEvent(event),
|
|
389
655
|
'multi-day-start': isMultiDayEvent(event) && getMultiDayPosition(event, day.date) === 'start',
|
|
390
656
|
'multi-day-middle': isMultiDayEvent(event) && getMultiDayPosition(event, day.date) === 'middle',
|
|
391
|
-
'multi-day-end': isMultiDayEvent(event) && getMultiDayPosition(event, day.date) === 'end'
|
|
657
|
+
'multi-day-end': isMultiDayEvent(event) && getMultiDayPosition(event, day.date) === 'end',
|
|
658
|
+
'drag-source': draggingEventId === event.id
|
|
392
659
|
}"
|
|
660
|
+
:draggable="eventDragEnabled"
|
|
661
|
+
@dragstart.stop="onEventDragStart(event, day.date, $event)"
|
|
662
|
+
@dragend.stop="onEventDragEnd"
|
|
393
663
|
@click.stop="handleEventClick(event)"
|
|
394
664
|
@contextmenu.stop="handleContextMenu(event, $event)"
|
|
395
665
|
>
|
|
@@ -482,6 +752,45 @@ watch([currentMonth, currentYear], () => {
|
|
|
482
752
|
min-height: 400px;
|
|
483
753
|
}
|
|
484
754
|
|
|
755
|
+
.calendar-grid.is-dragging-range,
|
|
756
|
+
.calendar-grid.is-dragging-event {
|
|
757
|
+
user-select: none;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
.calendar-day.range-single {
|
|
761
|
+
background-color: rgba(0, 123, 255, 0.22) !important;
|
|
762
|
+
box-shadow: inset 0 0 0 2px #007bff;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
.calendar-day.range-start {
|
|
766
|
+
background-color: rgba(0, 123, 255, 0.14) !important;
|
|
767
|
+
box-shadow:
|
|
768
|
+
inset 2px 0 0 0 #007bff,
|
|
769
|
+
inset 0 2px 0 0 #007bff,
|
|
770
|
+
inset 0 -2px 0 0 #007bff;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
.calendar-day.range-end {
|
|
774
|
+
background-color: rgba(0, 123, 255, 0.14) !important;
|
|
775
|
+
box-shadow:
|
|
776
|
+
inset -2px 0 0 0 #007bff,
|
|
777
|
+
inset 0 2px 0 0 #007bff,
|
|
778
|
+
inset 0 -2px 0 0 #007bff;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
.calendar-day.range-middle {
|
|
782
|
+
background-color: rgba(0, 123, 255, 0.14) !important;
|
|
783
|
+
box-shadow:
|
|
784
|
+
inset 0 2px 0 0 #007bff,
|
|
785
|
+
inset 0 -2px 0 0 #007bff;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
.calendar-day.drag-over-target {
|
|
789
|
+
background-color: rgba(0, 123, 255, 0.1) !important;
|
|
790
|
+
outline: 2px dashed #007bff;
|
|
791
|
+
outline-offset: -3px;
|
|
792
|
+
}
|
|
793
|
+
|
|
485
794
|
.day-header {
|
|
486
795
|
padding: 1rem;
|
|
487
796
|
text-align: center;
|
|
@@ -604,6 +913,18 @@ watch([currentMonth, currentYear], () => {
|
|
|
604
913
|
padding: 0.1rem 0.1rem;
|
|
605
914
|
}
|
|
606
915
|
|
|
916
|
+
.calendar-event[draggable='true'] {
|
|
917
|
+
cursor: grab;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
.calendar-event.drag-source {
|
|
921
|
+
opacity: 0.55;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
.calendar-event[draggable='true']:active {
|
|
925
|
+
cursor: grabbing;
|
|
926
|
+
}
|
|
927
|
+
|
|
607
928
|
.calendar-event:hover {
|
|
608
929
|
background: #b3de6e;
|
|
609
930
|
border-color: #c4db96;
|
|
@@ -777,6 +1098,40 @@ watch([currentMonth, currentYear], () => {
|
|
|
777
1098
|
background: #374151;
|
|
778
1099
|
}
|
|
779
1100
|
|
|
1101
|
+
.calendar-day.range-single {
|
|
1102
|
+
background-color: rgba(96, 165, 250, 0.28) !important;
|
|
1103
|
+
box-shadow: inset 0 0 0 2px #60a5fa;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
.calendar-day.range-start {
|
|
1107
|
+
background-color: rgba(96, 165, 250, 0.18) !important;
|
|
1108
|
+
box-shadow:
|
|
1109
|
+
inset 2px 0 0 0 #60a5fa,
|
|
1110
|
+
inset 0 2px 0 0 #60a5fa,
|
|
1111
|
+
inset 0 -2px 0 0 #60a5fa;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
.calendar-day.range-end {
|
|
1115
|
+
background-color: rgba(96, 165, 250, 0.18) !important;
|
|
1116
|
+
box-shadow:
|
|
1117
|
+
inset -2px 0 0 0 #60a5fa,
|
|
1118
|
+
inset 0 2px 0 0 #60a5fa,
|
|
1119
|
+
inset 0 -2px 0 0 #60a5fa;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
.calendar-day.range-middle {
|
|
1123
|
+
background-color: rgba(96, 165, 250, 0.18) !important;
|
|
1124
|
+
box-shadow:
|
|
1125
|
+
inset 0 2px 0 0 #60a5fa,
|
|
1126
|
+
inset 0 -2px 0 0 #60a5fa;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
.calendar-day.drag-over-target {
|
|
1130
|
+
background-color: rgba(96, 165, 250, 0.12) !important;
|
|
1131
|
+
outline: 2px dashed #60a5fa;
|
|
1132
|
+
outline-offset: -3px;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
780
1135
|
.calendar-day.past {
|
|
781
1136
|
opacity: 0.6;
|
|
782
1137
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
<div
|
|
4
4
|
:id = "sidebarEnabled ? 'sidebar-button' : null"
|
|
5
5
|
:class="['image-and-title', 'flex-row', { 'disabled-branding': !showBranding }]"
|
|
6
|
-
@click = "
|
|
6
|
+
@click = "onImageAndTitleClick"
|
|
7
7
|
>
|
|
8
8
|
<img v-if="showBranding" :src = "logoUrl" alt = "Logo" class = "logo" />
|
|
9
9
|
<h1 v-if="showBranding">{{ title }}</h1>
|
|
@@ -38,7 +38,15 @@
|
|
|
38
38
|
import Breadcrumbs from "./Breadcrumbs.vue";
|
|
39
39
|
import TopBar from "./TopBar.vue";
|
|
40
40
|
|
|
41
|
-
const emit = defineEmits(["toggleSidebar"]);
|
|
41
|
+
const emit = defineEmits(["toggleSidebar", "logoClick"]);
|
|
42
|
+
|
|
43
|
+
function onImageAndTitleClick() {
|
|
44
|
+
if (props.sidebarEnabled) {
|
|
45
|
+
emit("toggleSidebar");
|
|
46
|
+
} else {
|
|
47
|
+
emit("logoClick");
|
|
48
|
+
}
|
|
49
|
+
}
|
|
42
50
|
|
|
43
51
|
const props = defineProps({
|
|
44
52
|
breadcrumbs: {
|
package/vue/views/App.vue
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<Header username = "Guest" @toggleSidebar="toggleSidebar" title = "PicoCrank" :logoUrl="logoUrl" :sidebarEnabled="sidebarEnabled" :navigation="navigation" :topBarEnabled="topBarEnabled" :showBranding="brandingEnabled">
|
|
2
|
+
<Header username = "Guest" @toggleSidebar="toggleSidebar" title = "PicoCrank" :logoUrl="logoUrl" :sidebarEnabled="sidebarEnabled" :navigation="navigation" :topBarEnabled="topBarEnabled" :showBranding="brandingEnabled" :breadcrumbs="breadcrumbsEnabled">
|
|
3
3
|
<template #toolbar>
|
|
4
4
|
<QuickSearch
|
|
5
5
|
v-if="quickSearchEnabled"
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
const brandingEnabled = ref(true);
|
|
47
47
|
const quickSearchEnabled = ref(true);
|
|
48
48
|
const topBarEnabled = ref(false);
|
|
49
|
+
const breadcrumbsEnabled = ref(false);
|
|
49
50
|
|
|
50
51
|
function toggleSidebar() {
|
|
51
52
|
if (sidebar.value) {
|
|
@@ -69,7 +70,11 @@
|
|
|
69
70
|
topBarEnabled.value = !topBarEnabled.value;
|
|
70
71
|
}
|
|
71
72
|
|
|
72
|
-
|
|
73
|
+
function toggleBreadcrumbsEnabled() {
|
|
74
|
+
breadcrumbsEnabled.value = !breadcrumbsEnabled.value;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Provide sidebar, QuickSearch, TopBar, and breadcrumbs state and toggle functions for child components
|
|
73
78
|
provide('sidebarEnabled', sidebarEnabled);
|
|
74
79
|
provide('toggleSidebarEnabled', toggleSidebarEnabled);
|
|
75
80
|
provide('brandingEnabled', brandingEnabled);
|
|
@@ -79,6 +84,8 @@
|
|
|
79
84
|
provide('toggleQuickSearchEnabled', toggleQuickSearchEnabled);
|
|
80
85
|
provide('topBarEnabled', topBarEnabled);
|
|
81
86
|
provide('toggleTopBarEnabled', toggleTopBarEnabled);
|
|
87
|
+
provide('breadcrumbsEnabled', breadcrumbsEnabled);
|
|
88
|
+
provide('toggleBreadcrumbsEnabled', toggleBreadcrumbsEnabled);
|
|
82
89
|
|
|
83
90
|
onMounted(() => {
|
|
84
91
|
if (navigation.value) {
|
|
@@ -27,6 +27,10 @@
|
|
|
27
27
|
:current-year="currentYear"
|
|
28
28
|
@event-click="handleEventClick"
|
|
29
29
|
@date-click="handleDateClick"
|
|
30
|
+
@date-range-select="handleDateRangeSelect"
|
|
31
|
+
@event-move-request="handleEventMoveRequest"
|
|
32
|
+
@event-moved="handleEventMoved"
|
|
33
|
+
@event-move-rejected="handleEventMoveRejected"
|
|
30
34
|
@month-change="handleMonthChange"
|
|
31
35
|
/>
|
|
32
36
|
</Section>
|
|
@@ -187,8 +191,51 @@ function handleEventClick(event) {
|
|
|
187
191
|
}
|
|
188
192
|
|
|
189
193
|
function handleDateClick(date) {
|
|
194
|
+
// Single-day picks also emit `date-range-select`; keep this for integrations that only listen for `date-click`.
|
|
190
195
|
console.log('Date clicked:', date)
|
|
191
|
-
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function handleDateRangeSelect(start, end) {
|
|
199
|
+
const same = start.getTime() === end.getTime()
|
|
200
|
+
const label = same
|
|
201
|
+
? start.toLocaleDateString()
|
|
202
|
+
: `${start.toLocaleDateString()} – ${end.toLocaleDateString()}`
|
|
203
|
+
console.log('Date range:', start, end)
|
|
204
|
+
alert(`Range selected: ${label}`)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function handleEventMoveRequest({ respond, event, sourceDate, targetDate }) {
|
|
208
|
+
window.setTimeout(() => {
|
|
209
|
+
const ok = window.confirm(
|
|
210
|
+
`Move "${event.title}" to ${targetDate.toLocaleDateString()}? (Cancel = decline)`
|
|
211
|
+
)
|
|
212
|
+
respond(ok)
|
|
213
|
+
}, 50)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function applyEventDateShift(event, deltaMs) {
|
|
217
|
+
const i = events.value.findIndex(e => String(e.id) === String(event.id))
|
|
218
|
+
if (i === -1) return
|
|
219
|
+
const ev = events.value[i]
|
|
220
|
+
const next = { ...ev }
|
|
221
|
+
if (next.startDate != null && next.endDate != null) {
|
|
222
|
+
next.startDate = new Date(new Date(next.startDate).getTime() + deltaMs)
|
|
223
|
+
next.endDate = new Date(new Date(next.endDate).getTime() + deltaMs)
|
|
224
|
+
} else if (next.date != null) {
|
|
225
|
+
next.date = new Date(new Date(next.date).getTime() + deltaMs)
|
|
226
|
+
}
|
|
227
|
+
const copy = [...events.value]
|
|
228
|
+
copy[i] = next
|
|
229
|
+
events.value = copy
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function handleEventMoved({ event, sourceDate, targetDate }) {
|
|
233
|
+
const deltaMs = targetDate.getTime() - sourceDate.getTime()
|
|
234
|
+
applyEventDateShift(event, deltaMs)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function handleEventMoveRejected(payload) {
|
|
238
|
+
console.log('event-move-rejected', payload)
|
|
192
239
|
}
|
|
193
240
|
|
|
194
241
|
// Helper function to update the date picker value
|
package/vue/views/Welcome.vue
CHANGED
|
@@ -19,9 +19,12 @@
|
|
|
19
19
|
<span>Enable TopBar</span>
|
|
20
20
|
</label>
|
|
21
21
|
</div>
|
|
22
|
+
<button type="button" @click="toggleBreadcrumbsEnabled" :class="breadcrumbsEnabled ? 'neutral' : 'good'">
|
|
23
|
+
{{ breadcrumbsEnabled ? 'Hide Breadcrumbs' : 'Show Breadcrumbs' }}
|
|
24
|
+
</button>
|
|
22
25
|
</template>
|
|
23
26
|
<p>
|
|
24
|
-
Welcome to <strong>PicoCrank</strong>! This is a flexible example application to showcase a modular Vue 3 UI with a focus on ease of navigation, live UI controls, and modern design elements. Use the toolbar above to interactively enable or disable interface features like the sidebar, branding, QuickSearch,
|
|
27
|
+
Welcome to <strong>PicoCrank</strong>! This is a flexible example application to showcase a modular Vue 3 UI with a focus on ease of navigation, live UI controls, and modern design elements. Use the toolbar above to interactively enable or disable interface features like the sidebar, branding, QuickSearch, the top navigation bar, and route breadcrumbs in the header.
|
|
25
28
|
</p>
|
|
26
29
|
<p>
|
|
27
30
|
Explore the sections below to see reusable components in action, including toolbars, buttons, and layout options. PicoCrank is designed to be a starting point for your own rapid Vue projects—inspect the code and try customizing the UI!
|
|
@@ -106,4 +109,6 @@
|
|
|
106
109
|
const toggleQuickSearchEnabled = inject('toggleQuickSearchEnabled');
|
|
107
110
|
const topBarEnabled = inject('topBarEnabled');
|
|
108
111
|
const toggleTopBarEnabled = inject('toggleTopBarEnabled');
|
|
112
|
+
const breadcrumbsEnabled = inject('breadcrumbsEnabled');
|
|
113
|
+
const toggleBreadcrumbsEnabled = inject('toggleBreadcrumbsEnabled');
|
|
109
114
|
</script>
|