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 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="24"
4
- height="24"
5
- viewBox="0 0 24 24"
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="22.627417"
29
- inkscape:cx="35.465825"
30
- inkscape:cy="-0.41984466"
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="M21.011 14.097c.522-.141.783-.212.886-.346c.103-.135.103-.351.103-.784v-1.934c0-.433 0-.65-.103-.784s-.364-.205-.886-.345c-1.95-.526-3.171-2.565-2.668-4.503c.139-.533.208-.8.142-.956s-.256-.264-.635-.479l-1.725-.98c-.372-.21-.558-.316-.725-.294s-.356.21-.733.587c-1.459 1.455-3.873 1.455-5.333 0c-.377-.376-.565-.564-.732-.587c-.167-.022-.353.083-.725.295l-1.725.979c-.38.215-.57.323-.635.48c-.066.155.003.422.141.955c.503 1.938-.718 3.977-2.669 4.503c-.522.14-.783.21-.886.345S2 10.6 2 11.033v1.934c0 .433 0 .65.103.784s.364.205.886.346c1.95.526 3.171 2.565 2.668 4.502c-.139.533-.208.8-.142.956s.256.264.635.48l1.725.978c.372.212.558.317.725.295s.356-.21.733-.587c1.46-1.457 3.876-1.457 5.336 0c.377.376.565.564.732.587c.167.022.353-.083.726-.295l1.724-.979c.38-.215.57-.323.635-.48s-.003-.422-.141-.955c-.504-1.937.716-3.976 2.666-4.502"
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 15.63442,11.75922 a 3.5,3.5 0 1 1 -6.9999999,0 3.5,3.5 0 0 1 6.9999999,0"
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.14.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.0.0",
21
+ "@hugeicons/core-free-icons": "^4.1.1",
22
22
  "@hugeicons/vue": "^1.0.5",
23
- "@vitejs/plugin-vue": "^6.0.4",
23
+ "@vitejs/plugin-vue": "^6.0.6",
24
24
  "femtocrank": "^2.5.0",
25
- "unplugin-vue-components": "^31.0.0",
26
- "vite": "^7.3.1",
27
- "vue": "^3.5.30",
28
- "vue-router": "^5.0.3"
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
- import { watch } from 'vue'
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 class="calendar-grid" @wheel.prevent="handleWheel">
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 && day.date.toDateString() === new Date().toDateString(),
366
- 'weekend': day && (day.date.getDay() === 0 || day.date.getDay() === 6),
367
- 'prev-month': day && day.date.getMonth() !== currentMonth && day.date.getMonth() !== (currentMonth + 1) % 12,
368
- 'next-month': day && day.date.getMonth() !== currentMonth && day.date.getMonth() === (currentMonth + 1) % 12,
369
- 'past': day && isPastDay(day.date)
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 v-if="day" class="day-content" @click="handleDateClick(day.date)">
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 = "sidebarEnabled && emit('toggleSidebar')"
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
- // Provide sidebar, QuickSearch, and TopBar state and toggle functions for child components
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
- alert(`Date selected: ${date.toLocaleDateString()}`)
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
@@ -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, and the top navigation bar.
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&mdash;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>