picocrank 1.29.0 → 1.31.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "picocrank",
3
- "version": "1.29.0",
3
+ "version": "1.31.0",
4
4
  "type": "module",
5
5
  "description": "A set of zero dependency Vue components built on the Femtocrank global CSS theme.",
6
6
  "main": "vite.config.js",
@@ -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
- const firstOfMonth = new Date(date.getFullYear(), date.getMonth(), 1)
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
- // Anchor date for the 6x7 grid; we will shift this by weeks without re-rendering the grid structure
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
- for (let i = 0; i < 42; i++) {
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() + 21) // middle of 6x7 grid
507
+ mid.setDate(start.getDate() + (mode === 'week' ? 3 : 21))
321
508
  return { month: mid.getMonth(), year: mid.getFullYear() }
322
509
  }
323
510
 
324
- function adjustByWeeks(weeks) {
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
- // --- Date range selection (click / drag) ---
569
+ // --- Day context menu ---
367
570
 
368
- function startOfDay(d) {
369
- const x = new Date(d)
370
- x.setHours(0, 0, 0, 0)
371
- return x
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">{{ monthNames[viewMonth] }} {{ viewYear }}</h2>
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 v-for="day in dayNames" :key="day" class="day-header">{{ day }}</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.events.sort((a, b) => {
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 v-if="day.events.length > 3" class="more-events">
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>
@@ -6,11 +6,21 @@
6
6
  <div
7
7
  v-if="showActions"
8
8
  class="readonly-textarea-actions"
9
- :class="{ 'readonly-textarea-actions--leading': hasActions }"
10
9
  >
11
10
  <div v-if="hasActions" class="readonly-textarea-actions-slot">
12
11
  <slot name="actions" />
13
12
  </div>
13
+ <button
14
+ v-if="showClearButton"
15
+ type="button"
16
+ class="readonly-textarea-clear neutral"
17
+ title="Clear contents"
18
+ :disabled="!modelValue"
19
+ @click="clear"
20
+ >
21
+ <HugeiconsIcon :icon="Delete02Icon" width="1em" height="1em" />
22
+ <span>{{ clearLabel }}</span>
23
+ </button>
14
24
  <button
15
25
  v-if="showCopyButton"
16
26
  type="button"
@@ -39,7 +49,7 @@
39
49
  <script setup>
40
50
  import { ref, computed, useId, useSlots, watch, Comment } from 'vue'
41
51
  import { HugeiconsIcon } from '@hugeicons/vue'
42
- import { Copy01Icon, CopyCheckIcon } from '@hugeicons/core-free-icons'
52
+ import { Copy01Icon, CopyCheckIcon, Delete02Icon } from '@hugeicons/core-free-icons'
43
53
 
44
54
  const props = defineProps({
45
55
  modelValue: {
@@ -70,6 +80,10 @@ const props = defineProps({
70
80
  type: Boolean,
71
81
  default: true,
72
82
  },
83
+ showClearButton: {
84
+ type: Boolean,
85
+ default: false,
86
+ },
73
87
  copyLabel: {
74
88
  type: String,
75
89
  default: 'Copy',
@@ -78,6 +92,10 @@ const props = defineProps({
78
92
  type: String,
79
93
  default: 'Copied',
80
94
  },
95
+ clearLabel: {
96
+ type: String,
97
+ default: 'Clear',
98
+ },
81
99
  copiedFeedbackMs: {
82
100
  type: Number,
83
101
  default: 2000,
@@ -92,27 +110,40 @@ const props = defineProps({
92
110
  },
93
111
  })
94
112
 
95
- const emit = defineEmits(['copy', 'copy-error', 'update:modelValue'])
113
+ const emit = defineEmits(['copy', 'copy-error', 'clear', 'update:modelValue'])
96
114
 
97
115
  const slots = useSlots()
98
116
 
99
- const hasActions = computed(() => {
117
+ const slotActionCount = computed(() => {
100
118
  const render = slots.actions
101
119
  if (!render) {
102
- return false
120
+ return 0
103
121
  }
104
- return render().some((vnode) => vnode.type !== Comment)
122
+ return render().filter((vnode) => vnode.type !== Comment).length
105
123
  })
106
124
 
107
- const showActions = computed(() => hasActions.value || props.showCopyButton)
125
+ const hasActions = computed(() => slotActionCount.value > 0)
126
+
127
+ const builtInActionCount = computed(() => {
128
+ let count = 0
129
+ if (props.showClearButton) count++
130
+ if (props.showCopyButton) count++
131
+ return count
132
+ })
133
+
134
+ const actionButtonCount = computed(
135
+ () => slotActionCount.value + builtInActionCount.value,
136
+ )
137
+
138
+ const showActions = computed(
139
+ () => actionButtonCount.value > 0,
140
+ )
108
141
 
109
142
  const showHeader = computed(
110
143
  () => Boolean(props.label) || showActions.value,
111
144
  )
112
145
 
113
- const pushActionsEnd = computed(
114
- () => showActions.value && !hasActions.value,
115
- )
146
+ const pushActionsEnd = computed(() => showActions.value)
116
147
 
117
148
  const generatedId = useId()
118
149
  const textareaId = props.id || `readonly-textarea-${generatedId}`
@@ -156,6 +187,7 @@ function appendSection(sectionName) {
156
187
  function clear() {
157
188
  content.value = ''
158
189
  emit('update:modelValue', content.value)
190
+ emit('clear')
159
191
  }
160
192
 
161
193
  function getContentAsString() {
@@ -236,10 +268,6 @@ defineExpose({
236
268
  flex-shrink: 0;
237
269
  }
238
270
 
239
- .readonly-textarea-actions--leading {
240
- margin-left: 0;
241
- }
242
-
243
271
  .readonly-textarea-actions-slot {
244
272
  display: inline-flex;
245
273
  align-items: center;
@@ -247,7 +275,8 @@ defineExpose({
247
275
  flex-wrap: wrap;
248
276
  }
249
277
 
250
- .readonly-textarea-copy {
278
+ .readonly-textarea-copy,
279
+ .readonly-textarea-clear {
251
280
  display: inline-flex;
252
281
  align-items: center;
253
282
  gap: 0.35em;
@@ -7,9 +7,6 @@
7
7
  <HugeiconsIcon
8
8
  :icon="icon"
9
9
  class="section-title-icon"
10
- width="1.1em"
11
- height="1.1em"
12
- :strokeWidth="2"
13
10
  aria-hidden="true"
14
11
  />
15
12
  <span class="section-title-text">
@@ -3,7 +3,7 @@
3
3
  :id="SIDEBAR_PANEL_ID"
4
4
  ref="sidebarRef"
5
5
  class="sidebar"
6
- :class="{ 'shown': isOpen, 'stuck': isStuck }"
6
+ :class="{ 'shown': isVisible, 'stuck': isStuck }"
7
7
  aria-label="Sidebar"
8
8
  :aria-hidden="isVisible ? undefined : 'true'"
9
9
  :inert="isVisible ? undefined : true"
@@ -25,14 +25,14 @@
25
25
  :icon="Pin02Icon"
26
26
  width="1em"
27
27
  height="1em"
28
- :strokeWidth="3"
28
+ :strokeWidth="1.5"
29
29
  />
30
30
  <HugeiconsIcon
31
31
  v-else
32
32
  :icon="PinIcon"
33
33
  width="1em"
34
34
  height="1em"
35
- :strokeWidth="3"
35
+ :strokeWidth="1.5"
36
36
  />
37
37
  </span>
38
38
  </button>
@@ -274,8 +274,13 @@ function unstick() {
274
274
  }
275
275
 
276
276
  function toggle() {
277
+ if (isStuck.value) {
278
+ isStuck.value = false
279
+ isOpen.value = false
280
+ return
281
+ }
282
+
277
283
  isOpen.value = !isOpen.value
278
- isStuck.value = false
279
284
  }
280
285
 
281
286
  function open() {
@@ -284,7 +289,6 @@ function open() {
284
289
 
285
290
  function close() {
286
291
  isOpen.value = false
287
- isStuck.value = false
288
292
  }
289
293
 
290
294
  function isLinkActive(link) {
@@ -410,6 +414,28 @@ button {
410
414
  border: 0;
411
415
  }
412
416
 
417
+ .stick-toggle {
418
+ display: inline-flex;
419
+ align-items: center;
420
+ justify-content: center;
421
+ align-self: stretch;
422
+ flex-shrink: 0;
423
+ padding-top: 0;
424
+ padding-bottom: 0;
425
+ padding-left: 1em;
426
+ padding-right: 1em;
427
+ border-radius: 0;
428
+ color: var(--text-color);
429
+ background-color: transparent;
430
+ cursor: pointer;
431
+ }
432
+
433
+ .stick-toggle:hover,
434
+ .stick-toggle:focus-visible {
435
+ background-color: var(--sidebar-hover-bg-color);
436
+ color: var(--hover-text-color);
437
+ }
438
+
413
439
  .navigation-links,
414
440
  .nav-section-links {
415
441
  list-style: none;
@@ -222,11 +222,6 @@
222
222
  registerSidebarNavigation(navigation.value, { onHelloWorld: helloWorld })
223
223
  registerTopBarNavigation(topBarNavigation.value)
224
224
 
225
- if (sidebar.value) {
226
- sidebar.value.open();
227
- sidebar.value.stick();
228
- }
229
-
230
225
  registerQuickSearchItems()
231
226
  });
232
227
 
@@ -1,10 +1,16 @@
1
1
  <template>
2
- <Section
3
- title="Example Calendar"
4
- padding
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="short-month-suffix-toggle">
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
- // Generate initial events for current, previous, and next months
399
- const now = new Date()
400
- const initMonth = now.getMonth()
401
- const initYear = now.getFullYear()
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
- // Set initial month title using native JavaScript
405
- currentMonthIndex.value = now.getMonth()
406
- currentYear.value = now.getFullYear()
407
- currentMonth.value = now.toLocaleDateString('en-US', {
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
- // Initialize the native date picker with current month/year
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
- .short-month-suffix-toggle {
468
+ .calendar-example-toggle {
421
469
  display: inline-flex;
422
470
  align-items: center;
423
471
  gap: 0.35rem;
@@ -17,7 +17,9 @@
17
17
  v-model="diagnosticLog"
18
18
  label="Application log"
19
19
  :rows="10"
20
+ show-clear-button
20
21
  @copy="onCopy"
22
+ @clear="onClear"
21
23
  >
22
24
  <template #actions>
23
25
  <button type="button" @click="refreshDiagnostics">Refresh log</button>
@@ -28,6 +30,7 @@
28
30
  v-model="environmentInfo"
29
31
  label="Environment (JSON)"
30
32
  :rows="12"
33
+ show-clear-button
31
34
  markdown-ticks
32
35
  markdown-lang="json"
33
36
  />
@@ -37,14 +40,99 @@
37
40
  v-model="yamlConfig"
38
41
  label="Configuration (YAML)"
39
42
  :rows="12"
43
+ show-clear-button
40
44
  markdown-ticks
41
45
  markdown-lang="yaml"
42
46
  />
43
47
 
44
- <ReadOnlyTextArea
45
- :rows="4"
46
- placeholder="No diagnostics available."
47
- />
48
+ <div class="readonly-playground">
49
+ <p class="readonly-playground-intro">
50
+ Toggle options on the playground instance below to explore every
51
+ <code>ReadOnlyTextArea</code> prop and the actions slot.
52
+ </p>
53
+
54
+ <div
55
+ class="readonly-playground-controls"
56
+ role="group"
57
+ aria-label="ReadOnlyTextArea playground options"
58
+ >
59
+ <label class="readonly-example-toggle">
60
+ <input v-model="playground.labelEnabled" type="checkbox" />
61
+ Label
62
+ </label>
63
+ <label class="readonly-example-toggle">
64
+ <input v-model="playground.showCopyButton" type="checkbox" />
65
+ Copy button
66
+ </label>
67
+ <label class="readonly-example-toggle">
68
+ <input v-model="playground.showClearButton" type="checkbox" />
69
+ Clear button
70
+ </label>
71
+ <label class="readonly-example-toggle">
72
+ <input v-model="playground.showActions" type="checkbox" />
73
+ Actions slot
74
+ </label>
75
+ <label class="readonly-example-toggle">
76
+ <input v-model="playground.monospace" type="checkbox" />
77
+ Monospace
78
+ </label>
79
+ <label class="readonly-example-toggle">
80
+ <input v-model="playground.markdownTicks" type="checkbox" />
81
+ Markdown ticks
82
+ </label>
83
+ <label class="readonly-example-toggle">
84
+ <input
85
+ v-model="playground.markdownLangJson"
86
+ type="checkbox"
87
+ :disabled="!playground.markdownTicks"
88
+ />
89
+ Markdown lang: json
90
+ </label>
91
+ <label class="readonly-example-toggle">
92
+ <input v-model="playground.usePlaceholder" type="checkbox" />
93
+ Placeholder
94
+ </label>
95
+ <label class="readonly-example-toggle">
96
+ <input v-model="playground.customButtonLabels" type="checkbox" />
97
+ Custom button labels
98
+ </label>
99
+ <label class="readonly-example-toggle readonly-playground-rows">
100
+ <span>Rows</span>
101
+ <input
102
+ v-model.number="playground.rows"
103
+ type="number"
104
+ min="2"
105
+ max="24"
106
+ />
107
+ </label>
108
+ </div>
109
+
110
+ <ReadOnlyTextArea
111
+ v-model="playgroundContent"
112
+ :label="playground.labelEnabled ? 'Playground output' : ''"
113
+ :rows="playground.rows"
114
+ :placeholder="playground.usePlaceholder ? 'No diagnostics available.' : ''"
115
+ :monospace="playground.monospace"
116
+ :show-copy-button="playground.showCopyButton"
117
+ :show-clear-button="playground.showClearButton"
118
+ :copy-label="playground.customButtonLabels ? 'Copy all' : undefined"
119
+ :copied-label="playground.customButtonLabels ? 'Copied!' : undefined"
120
+ :clear-label="playground.customButtonLabels ? 'Reset' : undefined"
121
+ :markdown-ticks="playground.markdownTicks"
122
+ :markdown-lang="playground.markdownTicks && playground.markdownLangJson ? 'json' : ''"
123
+ @copy="onPlaygroundCopy"
124
+ @clear="onPlaygroundClear"
125
+ @copy-error="onPlaygroundCopyError"
126
+ >
127
+ <template v-if="playground.showActions" #actions>
128
+ <button type="button" @click="fillPlaygroundContent">Fill sample</button>
129
+ </template>
130
+ </ReadOnlyTextArea>
131
+
132
+ <p v-if="playgroundFeedback" class="copy-feedback subtle">
133
+ {{ playgroundFeedback }}
134
+ </p>
135
+ </div>
48
136
 
49
137
  <p v-if="lastCopied" class="copy-feedback subtle">
50
138
  Copied {{ lastCopied.length > 60 ? `${lastCopied.slice(0, 60)}…` : lastCopied }}
@@ -62,8 +150,40 @@ const diagnosticLog = ref('')
62
150
  const environmentInfo = ref('')
63
151
  const yamlConfig = ref('')
64
152
  const lastCopied = ref('')
153
+ const playgroundFeedback = ref('')
65
154
  const yamlConfigRef = ref(null)
66
155
 
156
+ const playground = ref({
157
+ labelEnabled: false,
158
+ showCopyButton: true,
159
+ showClearButton: false,
160
+ showActions: false,
161
+ monospace: true,
162
+ markdownTicks: false,
163
+ markdownLangJson: false,
164
+ usePlaceholder: false,
165
+ customButtonLabels: false,
166
+ rows: 4,
167
+ })
168
+
169
+ function buildPlaygroundSample() {
170
+ return [
171
+ 'Playground sample output',
172
+ 'Toggle the checkboxes above to try label, buttons, markdown fences, and monospace.',
173
+ '',
174
+ 'status: ok',
175
+ 'items: 3',
176
+ ].join('\n')
177
+ }
178
+
179
+ const playgroundContent = ref(buildPlaygroundSample())
180
+
181
+ function fillPlaygroundContent() {
182
+ playground.value.usePlaceholder = false
183
+ playgroundContent.value = buildPlaygroundSample()
184
+ playgroundFeedback.value = 'Filled playground with sample content.'
185
+ }
186
+
67
187
  function buildDiagnosticLog() {
68
188
  const now = new Date().toISOString()
69
189
  return [
@@ -122,6 +242,23 @@ function onCopy(value) {
122
242
  lastCopied.value = value.split('\n')[0]
123
243
  }
124
244
 
245
+ function onClear() {
246
+ lastCopied.value = ''
247
+ }
248
+
249
+ function onPlaygroundCopy(value) {
250
+ playgroundFeedback.value = `Copied ${value.split('\n').length} line(s).`
251
+ }
252
+
253
+ function onPlaygroundClear() {
254
+ playgroundContent.value = ''
255
+ playgroundFeedback.value = 'Playground cleared.'
256
+ }
257
+
258
+ function onPlaygroundCopyError(error) {
259
+ playgroundFeedback.value = `Copy failed: ${error?.message ?? error}`
260
+ }
261
+
125
262
  refreshDiagnostics()
126
263
  </script>
127
264
 
@@ -140,4 +277,34 @@ refreshDiagnostics()
140
277
  margin: 0;
141
278
  font-size: 0.875rem;
142
279
  }
280
+
281
+ .readonly-playground {
282
+ display: flex;
283
+ flex-direction: column;
284
+ gap: 0.75rem;
285
+ }
286
+
287
+ .readonly-playground-intro {
288
+ margin: 0;
289
+ }
290
+
291
+ .readonly-example-toggle {
292
+ display: inline-flex;
293
+ align-items: center;
294
+ gap: 0.35rem;
295
+ font-size: 0.875rem;
296
+ cursor: pointer;
297
+ user-select: none;
298
+ }
299
+
300
+ .readonly-playground-controls {
301
+ display: flex;
302
+ flex-wrap: wrap;
303
+ gap: 0.75rem 1rem;
304
+ }
305
+
306
+ .readonly-playground-rows input[type='number'] {
307
+ width: 4rem;
308
+ padding: 0.15rem 0.35rem;
309
+ }
143
310
  </style>