picocrank 1.28.2 → 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/styles/picocrank-extensions.css +23 -0
- package/vue/components/Calendar.vue +488 -37
- package/vue/components/Pagination.vue +4 -14
- package/vue/components/ReadOnlyTextArea.vue +69 -15
- package/vue/components/Sidebar.vue +21 -4
- package/vue/examples/CalendarExample.vue +71 -23
- package/vue/examples/ReadOnlyTextAreaExample.vue +5 -1
package/package.json
CHANGED
|
@@ -90,6 +90,8 @@
|
|
|
90
90
|
--danger-zone-summary-hover-bg: var(--karma-bad-tint);
|
|
91
91
|
--danger-zone-warning-fg: var(--muted-text-color);
|
|
92
92
|
--danger-zone-focus-outline: var(--karma-bad-border);
|
|
93
|
+
|
|
94
|
+
--picocrank-fixed-header-offset: 4em;
|
|
93
95
|
}
|
|
94
96
|
|
|
95
97
|
table thead th {
|
|
@@ -168,6 +170,27 @@ select.field-invalid:focus-visible {
|
|
|
168
170
|
outline: 2px solid var(--karma-bad-border);
|
|
169
171
|
outline-offset: 2px;
|
|
170
172
|
}
|
|
173
|
+
|
|
174
|
+
/*
|
|
175
|
+
* Fixed header layout: pin the app header and offset sidebar + main content.
|
|
176
|
+
* Apply .layout-fixed-header on a wrapper whose direct children are header and #layout.
|
|
177
|
+
*/
|
|
178
|
+
.layout-fixed-header > header {
|
|
179
|
+
position: fixed;
|
|
180
|
+
top: 0;
|
|
181
|
+
left: 0;
|
|
182
|
+
width: 100%;
|
|
183
|
+
z-index: 5;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
.layout-fixed-header > #layout aside {
|
|
187
|
+
z-index: 3;
|
|
188
|
+
padding-top: var(--picocrank-fixed-header-offset);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.layout-fixed-header > #layout main {
|
|
192
|
+
padding-top: var(--picocrank-fixed-header-offset);
|
|
193
|
+
}
|
|
171
194
|
}
|
|
172
195
|
|
|
173
196
|
/*
|
|
@@ -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>
|
|
@@ -108,12 +108,12 @@ const props = defineProps({
|
|
|
108
108
|
const emit = defineEmits(['page-change', 'page-size-change', 'update:page', 'update:pageSize'])
|
|
109
109
|
|
|
110
110
|
const localPageSize = ref(props.pageSize)
|
|
111
|
-
const localCurrentPage = ref(props.currentPage)
|
|
112
111
|
|
|
113
|
-
// Computed property to get the current page value (supports both v-model and regular props)
|
|
114
112
|
const currentPageValue = computed(() => {
|
|
115
|
-
//
|
|
116
|
-
|
|
113
|
+
// `page` supports v-model; `currentPage` is the plain prop. Both default to 1.
|
|
114
|
+
if (props.currentPage !== 1) {
|
|
115
|
+
return props.currentPage
|
|
116
|
+
}
|
|
117
117
|
return props.page
|
|
118
118
|
})
|
|
119
119
|
|
|
@@ -124,28 +124,18 @@ const endItem = computed(() => Math.min(currentPageValue.value * localPageSize.v
|
|
|
124
124
|
|
|
125
125
|
function goToPage(page) {
|
|
126
126
|
if (page >= 1 && page <= totalPages.value && page !== currentPageValue.value) {
|
|
127
|
-
localCurrentPage.value = page
|
|
128
127
|
emit('page-change', page)
|
|
129
128
|
emit('update:page', page)
|
|
130
129
|
}
|
|
131
130
|
}
|
|
132
131
|
|
|
133
132
|
function handlePageSizeChange() {
|
|
134
|
-
localCurrentPage.value = 1
|
|
135
133
|
emit('page-size-change', localPageSize.value)
|
|
136
134
|
emit('update:pageSize', localPageSize.value)
|
|
137
135
|
emit('page-change', 1)
|
|
138
136
|
emit('update:page', 1)
|
|
139
137
|
}
|
|
140
138
|
|
|
141
|
-
watch(() => props.currentPage, (newPage) => {
|
|
142
|
-
localCurrentPage.value = newPage
|
|
143
|
-
})
|
|
144
|
-
|
|
145
|
-
watch(() => props.page, (newPage) => {
|
|
146
|
-
localCurrentPage.value = newPage
|
|
147
|
-
})
|
|
148
|
-
|
|
149
139
|
watch(() => props.pageSize, (newSize) => {
|
|
150
140
|
localPageSize.value = newSize
|
|
151
141
|
})
|
|
@@ -1,19 +1,28 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div class="readonly-textarea">
|
|
3
|
-
<div v-if="
|
|
3
|
+
<div v-if="showHeader" class="readonly-textarea-header">
|
|
4
4
|
<label v-if="label" :for="textareaId">{{ label }}</label>
|
|
5
|
-
<div v-
|
|
6
|
-
<
|
|
7
|
-
v-if="
|
|
8
|
-
|
|
9
|
-
class="readonly-textarea-
|
|
10
|
-
:title="copied ? 'Copied' : 'Copy to clipboard'"
|
|
11
|
-
:disabled="!modelValue"
|
|
12
|
-
@click="copy"
|
|
5
|
+
<div v-if="pushActionsEnd" class="fg1" />
|
|
6
|
+
<div
|
|
7
|
+
v-if="showActions"
|
|
8
|
+
class="readonly-textarea-actions"
|
|
9
|
+
:class="{ 'readonly-textarea-actions--leading': hasActions }"
|
|
13
10
|
>
|
|
14
|
-
<
|
|
15
|
-
|
|
16
|
-
|
|
11
|
+
<div v-if="hasActions" class="readonly-textarea-actions-slot">
|
|
12
|
+
<slot name="actions" />
|
|
13
|
+
</div>
|
|
14
|
+
<button
|
|
15
|
+
v-if="showCopyButton"
|
|
16
|
+
type="button"
|
|
17
|
+
class="readonly-textarea-copy"
|
|
18
|
+
:title="copied ? 'Copied' : 'Copy to clipboard'"
|
|
19
|
+
:disabled="!modelValue"
|
|
20
|
+
@click="copy"
|
|
21
|
+
>
|
|
22
|
+
<HugeiconsIcon :icon="copied ? CopyCheckIcon : Copy01Icon" width="1em" height="1em" />
|
|
23
|
+
<span>{{ copied ? copiedLabel : copyLabel }}</span>
|
|
24
|
+
</button>
|
|
25
|
+
</div>
|
|
17
26
|
</div>
|
|
18
27
|
<textarea
|
|
19
28
|
:id="textareaId"
|
|
@@ -28,7 +37,7 @@
|
|
|
28
37
|
</template>
|
|
29
38
|
|
|
30
39
|
<script setup>
|
|
31
|
-
import { ref, computed, useId, watch } from 'vue'
|
|
40
|
+
import { ref, computed, useId, useSlots, watch, Comment } from 'vue'
|
|
32
41
|
import { HugeiconsIcon } from '@hugeicons/vue'
|
|
33
42
|
import { Copy01Icon, CopyCheckIcon } from '@hugeicons/core-free-icons'
|
|
34
43
|
|
|
@@ -85,6 +94,26 @@ const props = defineProps({
|
|
|
85
94
|
|
|
86
95
|
const emit = defineEmits(['copy', 'copy-error', 'update:modelValue'])
|
|
87
96
|
|
|
97
|
+
const slots = useSlots()
|
|
98
|
+
|
|
99
|
+
const hasActions = computed(() => {
|
|
100
|
+
const render = slots.actions
|
|
101
|
+
if (!render) {
|
|
102
|
+
return false
|
|
103
|
+
}
|
|
104
|
+
return render().some((vnode) => vnode.type !== Comment)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
const showActions = computed(() => hasActions.value || props.showCopyButton)
|
|
108
|
+
|
|
109
|
+
const showHeader = computed(
|
|
110
|
+
() => Boolean(props.label) || showActions.value,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
const pushActionsEnd = computed(
|
|
114
|
+
() => showActions.value && !hasActions.value,
|
|
115
|
+
)
|
|
116
|
+
|
|
88
117
|
const generatedId = useId()
|
|
89
118
|
const textareaId = props.id || `readonly-textarea-${generatedId}`
|
|
90
119
|
const textareaRef = ref(null)
|
|
@@ -180,12 +209,13 @@ defineExpose({
|
|
|
180
209
|
flex-direction: column;
|
|
181
210
|
gap: 0.5rem;
|
|
182
211
|
width: 100%;
|
|
212
|
+
min-width: 0;
|
|
213
|
+
max-width: 100%;
|
|
183
214
|
}
|
|
184
215
|
|
|
185
216
|
.readonly-textarea-header {
|
|
186
217
|
display: flex;
|
|
187
218
|
align-items: center;
|
|
188
|
-
justify-content: space-between;
|
|
189
219
|
gap: 0.75rem;
|
|
190
220
|
}
|
|
191
221
|
|
|
@@ -195,22 +225,46 @@ defineExpose({
|
|
|
195
225
|
|
|
196
226
|
.fg1 {
|
|
197
227
|
flex: 1;
|
|
228
|
+
min-width: 0;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
.readonly-textarea-actions {
|
|
232
|
+
display: inline-flex;
|
|
233
|
+
align-items: center;
|
|
234
|
+
gap: 0.75rem;
|
|
235
|
+
margin-left: auto;
|
|
236
|
+
flex-shrink: 0;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
.readonly-textarea-actions--leading {
|
|
240
|
+
margin-left: 0;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
.readonly-textarea-actions-slot {
|
|
244
|
+
display: inline-flex;
|
|
245
|
+
align-items: center;
|
|
246
|
+
gap: 0.75rem;
|
|
247
|
+
flex-wrap: wrap;
|
|
198
248
|
}
|
|
199
249
|
|
|
200
250
|
.readonly-textarea-copy {
|
|
201
251
|
display: inline-flex;
|
|
202
252
|
align-items: center;
|
|
203
253
|
gap: 0.35em;
|
|
254
|
+
flex-shrink: 0;
|
|
204
255
|
}
|
|
205
256
|
|
|
206
257
|
.readonly-textarea textarea {
|
|
207
258
|
width: 100%;
|
|
259
|
+
max-width: 100%;
|
|
260
|
+
box-sizing: border-box;
|
|
208
261
|
resize: vertical;
|
|
209
262
|
margin: 0;
|
|
210
263
|
}
|
|
211
264
|
|
|
212
265
|
.readonly-textarea textarea.monospace {
|
|
213
266
|
font-family: monospace;
|
|
214
|
-
white-space: pre;
|
|
267
|
+
white-space: pre-wrap;
|
|
268
|
+
overflow-wrap: anywhere;
|
|
215
269
|
}
|
|
216
270
|
</style>
|
|
@@ -107,7 +107,8 @@ import { HugeiconsIcon } from '@hugeicons/vue'
|
|
|
107
107
|
import { Pin02Icon, PinIcon, ArrowDown01Icon, ArrowRight01Icon } from '@hugeicons/core-free-icons'
|
|
108
108
|
import SidebarNavLink from './SidebarNavLink.vue'
|
|
109
109
|
|
|
110
|
-
const
|
|
110
|
+
const COLLAPSED_SECTIONS_STORAGE_KEY = 'picocrank-nav-sections-collapsed'
|
|
111
|
+
const STUCK_STORAGE_KEY = 'picocrank-sidebar-stuck'
|
|
111
112
|
const SIDEBAR_PANEL_ID = 'picocrank-sidebar'
|
|
112
113
|
const TOGGLER_ID = 'sidebar-toggler-button'
|
|
113
114
|
|
|
@@ -118,8 +119,16 @@ const props = defineProps({
|
|
|
118
119
|
},
|
|
119
120
|
})
|
|
120
121
|
|
|
122
|
+
function loadStuckPreference() {
|
|
123
|
+
try {
|
|
124
|
+
return localStorage.getItem(STUCK_STORAGE_KEY) === 'true'
|
|
125
|
+
} catch {
|
|
126
|
+
return false
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
121
130
|
const isOpen = ref(false)
|
|
122
|
-
const isStuck = ref(
|
|
131
|
+
const isStuck = ref(loadStuckPreference())
|
|
123
132
|
const sidebarRef = ref(null)
|
|
124
133
|
const route = useRoute()
|
|
125
134
|
const headingId = useId()
|
|
@@ -156,7 +165,7 @@ function isActive(link) {
|
|
|
156
165
|
|
|
157
166
|
function loadCollapsedSections() {
|
|
158
167
|
try {
|
|
159
|
-
const raw = localStorage.getItem(
|
|
168
|
+
const raw = localStorage.getItem(COLLAPSED_SECTIONS_STORAGE_KEY)
|
|
160
169
|
if (!raw) return new Set()
|
|
161
170
|
const parsed = JSON.parse(raw)
|
|
162
171
|
return new Set(Array.isArray(parsed) ? parsed : [])
|
|
@@ -168,7 +177,7 @@ function loadCollapsedSections() {
|
|
|
168
177
|
const collapsedSections = ref(loadCollapsedSections())
|
|
169
178
|
|
|
170
179
|
function saveCollapsedSections() {
|
|
171
|
-
localStorage.setItem(
|
|
180
|
+
localStorage.setItem(COLLAPSED_SECTIONS_STORAGE_KEY, JSON.stringify([...collapsedSections.value]))
|
|
172
181
|
}
|
|
173
182
|
|
|
174
183
|
function isSectionCollapsed(sectionId) {
|
|
@@ -244,6 +253,14 @@ watch(() => route.fullPath, () => {
|
|
|
244
253
|
expandSectionContainingActiveRoute()
|
|
245
254
|
}, { immediate: true })
|
|
246
255
|
|
|
256
|
+
watch(isStuck, (stuck) => {
|
|
257
|
+
try {
|
|
258
|
+
localStorage.setItem(STUCK_STORAGE_KEY, stuck ? 'true' : 'false')
|
|
259
|
+
} catch {
|
|
260
|
+
// ignore storage errors (private mode, quota, etc.)
|
|
261
|
+
}
|
|
262
|
+
})
|
|
263
|
+
|
|
247
264
|
function toggleStick() {
|
|
248
265
|
isStuck.value = !isStuck.value
|
|
249
266
|
}
|
|
@@ -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;
|
|
@@ -18,7 +18,11 @@
|
|
|
18
18
|
label="Application log"
|
|
19
19
|
:rows="10"
|
|
20
20
|
@copy="onCopy"
|
|
21
|
-
|
|
21
|
+
>
|
|
22
|
+
<template #actions>
|
|
23
|
+
<button type="button" @click="refreshDiagnostics">Refresh log</button>
|
|
24
|
+
</template>
|
|
25
|
+
</ReadOnlyTextArea>
|
|
22
26
|
|
|
23
27
|
<ReadOnlyTextArea
|
|
24
28
|
v-model="environmentInfo"
|