picocrank 1.4.0 → 1.6.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.4.0",
3
+ "version": "1.6.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": {
package/router.js CHANGED
@@ -4,6 +4,7 @@ import { HomeIcon } from '@hugeicons/core-free-icons';
4
4
  import { TableIcon } from '@hugeicons/core-free-icons';
5
5
  import { ViewIcon } from '@hugeicons/core-free-icons';
6
6
  import { SecurityValidationIcon } from '@hugeicons/core-free-icons';
7
+ import { CalendarIcon } from '@hugeicons/core-free-icons';
7
8
 
8
9
  const routes = [
9
10
  {
@@ -43,6 +44,16 @@ const routes = [
43
44
  title: 'Admin',
44
45
  icon: SecurityValidationIcon,
45
46
  }
47
+ },
48
+ {
49
+ name: 'CalendarExample',
50
+ path: '/calendar-example',
51
+ component: () => import('./vue/views/CalendarExample.vue'),
52
+ props: true,
53
+ meta: {
54
+ title: 'Calendar Example',
55
+ icon: CalendarIcon,
56
+ }
46
57
  }
47
58
  ];
48
59
 
@@ -0,0 +1,696 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed } from 'vue'
3
+
4
+ export interface CalendarEvent {
5
+ id: string | number
6
+ title: string
7
+ startDate?: Date | string | null
8
+ endDate?: Date | string | null
9
+ date?: Date | string | null
10
+ [key: string]: any
11
+ }
12
+
13
+ export interface CalendarProps {
14
+ events: CalendarEvent[]
15
+ monthNames?: string[]
16
+ dayNames?: string[]
17
+ loading?: boolean
18
+ error?: string | null
19
+ // Formatter functions
20
+ getEventDate?: (event: CalendarEvent) => Date | null
21
+ getEventDateRange?: (event: CalendarEvent) => { start: Date | null; end: Date | null }
22
+ formatEventTime?: (event: CalendarEvent, date: Date) => string
23
+ // Customization
24
+ showNavigation?: boolean
25
+ currentMonth?: number
26
+ currentYear?: number
27
+ }
28
+
29
+ const props = withDefaults(defineProps<CalendarProps>(), {
30
+ section: false,
31
+ monthNames: () => [
32
+ 'January', 'February', 'March', 'April', 'May', 'June',
33
+ 'July', 'August', 'September', 'October', 'November', 'December'
34
+ ],
35
+ dayNames: () => ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
36
+ loading: false,
37
+ error: null,
38
+ showNavigation: true,
39
+ })
40
+
41
+ const emit = defineEmits<{
42
+ 'event-click': [event: CalendarEvent]
43
+ 'date-click': [date: Date]
44
+ 'month-change': [month: number, year: number]
45
+ 'event-context-menu': [event: CalendarEvent, mouseEvent: MouseEvent]
46
+ }>()
47
+
48
+ // Internal calendar state
49
+ const internalCurrentDate = ref(new Date())
50
+ const currentDate = computed(() => {
51
+ if (props.currentMonth !== undefined && props.currentYear !== undefined) {
52
+ return new Date(props.currentYear, props.currentMonth, 1)
53
+ }
54
+ return internalCurrentDate.value
55
+ })
56
+
57
+ const currentMonth = computed(() => currentDate.value.getMonth())
58
+ const currentYear = computed(() => currentDate.value.getFullYear())
59
+
60
+ // Helper function to check if a time is midnight (00:00)
61
+ function isMidnight(dateValue: any): boolean {
62
+ if (!dateValue) return false
63
+ const date = new Date(dateValue)
64
+ return date.getHours() === 0 && date.getMinutes() === 0
65
+ }
66
+
67
+ // Helper function to format time, returning "No time" if midnight
68
+ function formatTimeOrNoTime(dateValue: any): string {
69
+ if (!dateValue) return 'No time'
70
+ if (isMidnight(dateValue)) return 'No time'
71
+ const date = new Date(dateValue)
72
+ return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
73
+ }
74
+
75
+ // Get event date range
76
+ function getEventDateRange(event: CalendarEvent): { start: Date | null; end: Date | null } {
77
+ if (props.getEventDateRange) {
78
+ return props.getEventDateRange(event)
79
+ }
80
+
81
+ // Default implementation
82
+ let start: Date | null = null
83
+ let end: Date | null = null
84
+
85
+ if (event.startDate) {
86
+ start = new Date(event.startDate)
87
+ } else if (event.date) {
88
+ start = new Date(event.date)
89
+ }
90
+
91
+ if (event.endDate) {
92
+ end = new Date(event.endDate)
93
+ }
94
+
95
+ return { start, end }
96
+ }
97
+
98
+ // Get events for a specific date
99
+ function getEventsForDate(date: Date): CalendarEvent[] {
100
+ const targetDate = new Date(date)
101
+ targetDate.setHours(0, 0, 0, 0)
102
+
103
+ return props.events.filter(event => {
104
+ const { start, end } = getEventDateRange(event)
105
+
106
+ if (!start) return false
107
+
108
+ const startDateOnly = new Date(start)
109
+ startDateOnly.setHours(0, 0, 0, 0)
110
+
111
+ if (end) {
112
+ const endDateOnly = new Date(end)
113
+ endDateOnly.setHours(0, 0, 0, 0)
114
+ return targetDate >= startDateOnly && targetDate <= endDateOnly
115
+ } else {
116
+ return targetDate.getTime() === startDateOnly.getTime()
117
+ }
118
+ })
119
+ }
120
+
121
+ // Check if an event is a multi-day event
122
+ function isMultiDayEvent(event: CalendarEvent): boolean {
123
+ const { start, end } = getEventDateRange(event)
124
+
125
+ if (!start || !end) return false
126
+
127
+ const startDate = new Date(start)
128
+ const endDate = new Date(end)
129
+
130
+ startDate.setHours(0, 0, 0, 0)
131
+ endDate.setHours(0, 0, 0, 0)
132
+
133
+ return startDate.getTime() !== endDate.getTime()
134
+ }
135
+
136
+ // Get the position of an event within a multi-day range for a specific date
137
+ function getMultiDayPosition(event: CalendarEvent, date: Date): 'start' | 'middle' | 'end' | 'single' {
138
+ const { start, end } = getEventDateRange(event)
139
+
140
+ if (!start || !end) return 'single'
141
+
142
+ const startDate = new Date(start)
143
+ const endDate = new Date(end)
144
+ const targetDate = new Date(date)
145
+
146
+ startDate.setHours(0, 0, 0, 0)
147
+ endDate.setHours(0, 0, 0, 0)
148
+ targetDate.setHours(0, 0, 0, 0)
149
+
150
+ if (startDate.getTime() === endDate.getTime()) return 'single'
151
+ if (targetDate.getTime() === startDate.getTime()) return 'start'
152
+ if (targetDate.getTime() === endDate.getTime()) return 'end'
153
+ if (targetDate > startDate && targetDate < endDate) return 'middle'
154
+
155
+ return 'single'
156
+ }
157
+
158
+ // Format event time based on position in multi-day event
159
+ function formatEventTimeDefault(event: CalendarEvent, date: Date): string {
160
+ if (props.formatEventTime) {
161
+ return props.formatEventTime(event, date)
162
+ }
163
+
164
+ const { start, end } = getEventDateRange(event)
165
+ const position = getMultiDayPosition(event, date)
166
+
167
+ if (!start || !end) return 'No time'
168
+
169
+ if (position === 'start') {
170
+ return formatTimeOrNoTime(start)
171
+ } else if (position === 'end') {
172
+ return formatTimeOrNoTime(end)
173
+ } else if (position === 'middle') {
174
+ return 'All day'
175
+ } else if (position === 'single') {
176
+ return formatTimeOrNoTime(start)
177
+ }
178
+
179
+ return 'All day'
180
+ }
181
+
182
+ // Calendar generation
183
+ const calendarDays = computed(() => {
184
+ const year = currentYear.value
185
+ const month = currentMonth.value
186
+
187
+ const firstDay = new Date(year, month, 1)
188
+ const lastDay = new Date(year, month + 1, 0)
189
+ const daysInMonth = lastDay.getDate()
190
+
191
+ // Get starting day of week (Monday as first day)
192
+ const startDay = (firstDay.getDay() + 6) % 7
193
+
194
+ const days = []
195
+
196
+ // Add days from previous month to fill the first row
197
+ const prevMonth = month === 0 ? 11 : month - 1
198
+ const prevYear = month === 0 ? year - 1 : year
199
+ const prevMonthLastDay = new Date(prevYear, prevMonth + 1, 0).getDate()
200
+
201
+ for (let i = 0; i < startDay; i++) {
202
+ const day = prevMonthLastDay - startDay + i + 1
203
+ const date = new Date(prevYear, prevMonth, day)
204
+ days.push({
205
+ date,
206
+ events: getEventsForDate(date)
207
+ })
208
+ }
209
+
210
+ // Add days of the month
211
+ for (let day = 1; day <= daysInMonth; day++) {
212
+ const date = new Date(year, month, day)
213
+ days.push({
214
+ date,
215
+ events: getEventsForDate(date)
216
+ })
217
+ }
218
+
219
+ // Add days from next month to complete the last row
220
+ const totalCells = 42
221
+ const remainingCells = totalCells - days.length
222
+
223
+ for (let day = 1; day <= remainingCells; day++) {
224
+ const date = new Date(year, month + 1, day)
225
+ days.push({
226
+ date,
227
+ events: getEventsForDate(date)
228
+ })
229
+ }
230
+
231
+ return days
232
+ })
233
+
234
+ // Navigation functions
235
+ function previousMonth() {
236
+ if (props.currentMonth !== undefined && props.currentYear !== undefined) {
237
+ const newMonth = currentMonth.value === 0 ? 11 : currentMonth.value - 1
238
+ const newYear = currentMonth.value === 0 ? currentYear.value - 1 : currentYear.value
239
+ emit('month-change', newMonth, newYear)
240
+ } else {
241
+ internalCurrentDate.value = new Date(currentYear.value, currentMonth.value - 1, 1)
242
+ }
243
+ }
244
+
245
+ function nextMonth() {
246
+ if (props.currentMonth !== undefined && props.currentYear !== undefined) {
247
+ const newMonth = currentMonth.value === 11 ? 0 : currentMonth.value + 1
248
+ const newYear = currentMonth.value === 11 ? currentYear.value + 1 : currentYear.value
249
+ emit('month-change', newMonth, newYear)
250
+ } else {
251
+ internalCurrentDate.value = new Date(currentYear.value, currentMonth.value + 1, 1)
252
+ }
253
+ }
254
+
255
+ function goToToday() {
256
+ if (props.currentMonth !== undefined && props.currentYear !== undefined) {
257
+ const today = new Date()
258
+ emit('month-change', today.getMonth(), today.getFullYear())
259
+ } else {
260
+ internalCurrentDate.value = new Date()
261
+ }
262
+ }
263
+
264
+ // Event handlers
265
+ function handleEventClick(event: CalendarEvent) {
266
+ emit('event-click', event)
267
+ }
268
+
269
+ function handleDateClick(date: Date) {
270
+ emit('date-click', date)
271
+ }
272
+
273
+ function handleContextMenu(event: CalendarEvent, mouseEvent: MouseEvent) {
274
+ mouseEvent.preventDefault()
275
+ mouseEvent.stopPropagation()
276
+ emit('event-context-menu', event, mouseEvent)
277
+ }
278
+ </script>
279
+
280
+ <template>
281
+ <div class="calendar-wrapper">
282
+ <div v-if="showNavigation" class="calendar-header-nav">
283
+ <h2 class="calendar-title">{{ monthNames[currentMonth] }} {{ currentYear }}</h2>
284
+ <div class="calendar-nav-buttons">
285
+ <slot name="nav-buttons">
286
+ <button @click="previousMonth" class="button neutral">‹</button>
287
+ <button @click="goToToday" class="button neutral">Today</button>
288
+ <button @click="nextMonth" class="button neutral">›</button>
289
+ </slot>
290
+ </div>
291
+ </div>
292
+
293
+ <div v-if="error" class="calendar-error">{{ error }}</div>
294
+ <div v-else-if="loading" class="calendar-loading">Loading…</div>
295
+ <div v-else class="calendar-container">
296
+ <div class="calendar-grid">
297
+ <!-- Day headers -->
298
+ <div v-for="day in dayNames" :key="day" class="day-header">{{ day }}</div>
299
+
300
+ <!-- Calendar days -->
301
+ <div
302
+ v-for="(day, index) in calendarDays"
303
+ :key="index"
304
+ class="calendar-day"
305
+ :class="{
306
+ 'today': day && day.date.toDateString() === new Date().toDateString(),
307
+ 'weekend': day && (day.date.getDay() === 0 || day.date.getDay() === 6),
308
+ 'prev-month': day && day.date.getMonth() !== currentMonth && day.date.getMonth() !== (currentMonth + 1) % 12,
309
+ 'next-month': day && day.date.getMonth() !== currentMonth && day.date.getMonth() === (currentMonth + 1) % 12
310
+ }"
311
+ >
312
+ <div v-if="day" class="day-content" @click="handleDateClick(day.date)">
313
+ <div class="day-number clickable">
314
+ {{ day.date.getDate() }}
315
+ </div>
316
+ <div class="day-events">
317
+ <div
318
+ v-for="event in day.events.sort((a, b) => {
319
+ const aMultiDay = isMultiDayEvent(a);
320
+ const bMultiDay = isMultiDayEvent(b);
321
+ if (aMultiDay && !bMultiDay) return -1;
322
+ if (!aMultiDay && bMultiDay) return 1;
323
+ return 0;
324
+ }).slice(0, 3)"
325
+ :key="event.id"
326
+ class="calendar-event"
327
+ :class="{
328
+ 'multi-day': isMultiDayEvent(event),
329
+ 'multi-day-start': isMultiDayEvent(event) && getMultiDayPosition(event, day.date) === 'start',
330
+ 'multi-day-middle': isMultiDayEvent(event) && getMultiDayPosition(event, day.date) === 'middle',
331
+ 'multi-day-end': isMultiDayEvent(event) && getMultiDayPosition(event, day.date) === 'end'
332
+ }"
333
+ @click.stop="handleEventClick(event)"
334
+ @contextmenu.stop="handleContextMenu(event, $event)"
335
+ >
336
+ <slot name="event" :event="event" :date="day.date" :position="getMultiDayPosition(event, day.date)">
337
+ <div class="event-content">
338
+ <div class="event-title">
339
+ {{ event.title }}
340
+ <span v-if="isMultiDayEvent(event)" class="multi-day-indicator">
341
+ {{ getMultiDayPosition(event, day.date) === 'start' ? '▶' :
342
+ getMultiDayPosition(event, day.date) === 'end' ? '◀' :
343
+ getMultiDayPosition(event, day.date) === 'middle' ? '▬' : '' }}
344
+ </span>
345
+ </div>
346
+ <div class="event-time">
347
+ {{ formatEventTimeDefault(event, day.date) }}
348
+ </div>
349
+ </div>
350
+ </slot>
351
+ </div>
352
+ <div v-if="day.events.length > 3" class="more-events">
353
+ +{{ day.events.length - 3 }} more
354
+ </div>
355
+ </div>
356
+ </div>
357
+ </div>
358
+ </div>
359
+ </div>
360
+ </div>
361
+ </template>
362
+
363
+ <style scoped>
364
+ .calendar-wrapper {
365
+ width: 100%;
366
+ }
367
+
368
+ .calendar-header-nav {
369
+ display: flex;
370
+ justify-content: space-between;
371
+ align-items: center;
372
+ margin-bottom: 1rem;
373
+ padding: 1rem;
374
+ background: #f8f9fa;
375
+ border-radius: 8px;
376
+ }
377
+
378
+ .calendar-title {
379
+ margin: 0;
380
+ font-size: 1.5rem;
381
+ font-weight: 600;
382
+ }
383
+
384
+ .calendar-nav-buttons {
385
+ display: flex;
386
+ align-items: center;
387
+ gap: 0.5rem;
388
+ }
389
+
390
+ .calendar-error {
391
+ color: #b00020;
392
+ padding: 1rem;
393
+ }
394
+
395
+ .calendar-loading {
396
+ padding: 1rem;
397
+ text-align: center;
398
+ }
399
+
400
+ .calendar-container {
401
+ background: white;
402
+ border: 1px solid #e0e0e0;
403
+ overflow: hidden;
404
+ border-radius: 8px;
405
+ }
406
+
407
+ .calendar-grid {
408
+ display: grid;
409
+ grid-template-columns: repeat(7, minmax(0, 1fr));
410
+ min-height: 400px;
411
+ }
412
+
413
+ .day-header {
414
+ padding: 1rem;
415
+ text-align: center;
416
+ font-weight: 600;
417
+ color: #666;
418
+ border-right: 1px solid #e0e0e0;
419
+ border-bottom: 1px solid #e0e0e0;
420
+ background: #f8f9fa;
421
+ }
422
+
423
+ .day-header:last-child {
424
+ border-right: none;
425
+ }
426
+
427
+ .calendar-day {
428
+ border-right: 1px solid #e0e0e0;
429
+ border-bottom: 1px solid #e0e0e0;
430
+ min-height: 120px;
431
+ position: relative;
432
+ transition: background-color 0.2s ease;
433
+ cursor: pointer;
434
+ }
435
+
436
+ .calendar-day:hover {
437
+ background-color: #f0f8ff;
438
+ }
439
+
440
+ .calendar-day:nth-child(7n) {
441
+ border-right: none;
442
+ }
443
+
444
+ .calendar-day.weekend {
445
+ background: #f8f9fa;
446
+ }
447
+
448
+ .calendar-day.weekend:hover {
449
+ background: #e9ecef;
450
+ }
451
+
452
+ .calendar-day.next-month,
453
+ .calendar-day.prev-month {
454
+ background: #f8f9fa;
455
+ opacity: 0.6;
456
+ }
457
+
458
+ .calendar-day.next-month:hover,
459
+ .calendar-day.prev-month:hover {
460
+ background: #e9ecef;
461
+ opacity: 0.8;
462
+ }
463
+
464
+ .calendar-day.today {
465
+ background: #f7f8d7;
466
+ font-weight: bold;
467
+ }
468
+
469
+ .calendar-day.today:hover {
470
+ background: #bbdefb;
471
+ }
472
+
473
+
474
+ .day-content {
475
+ height: 100%;
476
+ display: flex;
477
+ flex-direction: column;
478
+ }
479
+
480
+ .day-number {
481
+ font-size: 0.9rem;
482
+ margin-bottom: 0.25rem;
483
+ color: #333;
484
+ text-decoration: none;
485
+ display: inline-block;
486
+ padding: 0.1rem;
487
+ border-radius: 4px;
488
+ transition: all 0.2s;
489
+ min-width: 1.5rem;
490
+ text-align: center;
491
+ }
492
+
493
+ .day-number.clickable {
494
+ cursor: pointer;
495
+ padding: 0.25rem;
496
+ border-radius: 4px;
497
+ transition: background-color 0.2s;
498
+ }
499
+
500
+ .day-content:hover .day-number.clickable {
501
+ color: #007bff;
502
+ }
503
+
504
+ .day-events {
505
+ flex: 1;
506
+ overflow: hidden;
507
+ }
508
+
509
+ .calendar-event {
510
+ background: #d6f1aa;
511
+ border: 1px solid #c4db96;
512
+ margin-bottom: 0.25rem;
513
+ cursor: pointer;
514
+ transition: all 0.2s;
515
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
516
+ padding: 0.1rem 0.1rem;
517
+ }
518
+
519
+ .calendar-event:hover {
520
+ background: #b3de6e;
521
+ border-color: #c4db96;
522
+ transform: translateY(-1px);
523
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15);
524
+ }
525
+
526
+ .calendar-event.multi-day-start {
527
+ border-top-left-radius: 4px;
528
+ border-bottom-left-radius: 4px;
529
+ }
530
+
531
+ .calendar-event.multi-day-middle {
532
+ border-radius: 0;
533
+ }
534
+
535
+ .calendar-event.multi-day-end {
536
+ border-top-right-radius: 4px;
537
+ border-bottom-right-radius: 4px;
538
+ }
539
+
540
+ .event-content {
541
+ cursor: pointer;
542
+ }
543
+
544
+ .event-title {
545
+ font-weight: bold;
546
+ font-size: 0.85rem;
547
+ color: #333;
548
+ white-space: normal;
549
+ overflow: hidden;
550
+ text-overflow: ellipsis;
551
+ display: flex;
552
+ align-items: center;
553
+ gap: 0.25rem;
554
+ }
555
+
556
+ .multi-day-indicator {
557
+ font-size: 0.7rem;
558
+ color: #007bff;
559
+ font-weight: bold;
560
+ }
561
+
562
+ .event-time {
563
+ font-size: 0.75rem;
564
+ color: #666;
565
+ margin-top: 0.125rem;
566
+ }
567
+
568
+ .more-events {
569
+ font-size: 0.75rem;
570
+ color: #666;
571
+ text-align: center;
572
+ padding: 0.25rem;
573
+ background: #f8f9fa;
574
+ border-radius: 4px;
575
+ margin-top: 0.25rem;
576
+ }
577
+
578
+ /* Responsive design */
579
+ @media (max-width: 768px) {
580
+ .calendar-header-nav {
581
+ flex-wrap: wrap;
582
+ gap: 0.5rem;
583
+ }
584
+
585
+ .calendar-title {
586
+ font-size: 1.2rem;
587
+ }
588
+
589
+ .calendar-day {
590
+ min-height: 80px;
591
+ }
592
+
593
+ .day-number {
594
+ font-size: 1rem;
595
+ }
596
+
597
+ .event-title {
598
+ font-size: 0.8rem;
599
+ }
600
+
601
+ .event-time {
602
+ font-size: 0.7rem;
603
+ }
604
+ }
605
+
606
+ @media (max-width: 480px) {
607
+ .calendar-grid {
608
+ min-height: 300px;
609
+ }
610
+
611
+ .calendar-day {
612
+ min-height: 60px;
613
+ }
614
+
615
+ .day-header {
616
+ padding: 0.5rem 0.25rem;
617
+ font-size: 0.8rem;
618
+ }
619
+ }
620
+
621
+ @media (min-width: 768px) {
622
+ .day-content {
623
+ padding: 0.45rem;
624
+ }
625
+
626
+ .calendar-event {
627
+ border-radius: 4px;
628
+ padding: 0.25rem 0.5rem;
629
+ }
630
+ }
631
+
632
+ @media (prefers-color-scheme: dark) {
633
+ .calendar-container {
634
+ background: #565656;
635
+ border-color: #374151;
636
+ border: 1px solid #565656;
637
+ }
638
+
639
+ .calendar-day.today {
640
+ background: #646c70;
641
+ }
642
+
643
+ .calendar-day.weekend {
644
+ background: #444;
645
+ }
646
+
647
+ .calendar-day.weekend:hover {
648
+ background: #1a1a1a;
649
+ }
650
+
651
+ .calendar-day.next-month,
652
+ .calendar-day.prev-month {
653
+ background: #1a1a1a !important;
654
+ }
655
+
656
+ .calendar-day.next-month:hover,
657
+ .calendar-day.prev-month:hover {
658
+ background: #374151;
659
+ }
660
+
661
+ .calendar-day.today:hover {
662
+ background: #374151;
663
+ }
664
+
665
+ .calendar-day {
666
+ border: 1px solid #3b3b3b;
667
+ }
668
+
669
+ .day-number {
670
+ color: #f9fafb;
671
+ }
672
+
673
+ .day-header {
674
+ color: #f9fafb;
675
+ background: #444 !important;
676
+ border: 1px solid #374151;
677
+ border-color: #374151;
678
+ }
679
+
680
+ .day-content:hover .day-number.clickable {
681
+ color: #f9fafb;
682
+ }
683
+
684
+ .calendar-day:hover {
685
+ background-color: #374151;
686
+ }
687
+
688
+ .calendar-day.today:hover {
689
+ background: #374151;
690
+ }
691
+
692
+ .calendar-day.weekend:hover {
693
+ background: #374151;
694
+ }
695
+ }
696
+ </style>
@@ -2,15 +2,28 @@
2
2
  <div class="pagination">
3
3
  <div class="pagination-info">
4
4
  <span class="pagination-text">
5
- Showing {{ startItem + 1 }}-{{ endItem }} of {{ total }} {{ itemTitle }}
5
+ Page size:
6
+ <select
7
+ id="page-size"
8
+ v-model="localPageSize"
9
+ @change="handlePageSizeChange"
10
+ class="page-size-select"
11
+ >
12
+ <option value="10">10</option>
13
+ <option value="25">25</option>
14
+ <option value="50">50</option>
15
+ <option value="100">100</option>
16
+ </select>
17
+
18
+ Showing {{ startItem + 1 }}-{{ endItem }} of {{ total }} {{ itemTitle }}
6
19
  </span>
7
20
  </div>
8
21
 
9
22
  <div class="pagination-controls">
10
23
  <button
11
- class="pagination-btn"
12
- :disabled="currentPage === 1"
13
- @click="goToPage(currentPage - 1)"
24
+ class="button"
25
+ :disabled="currentPageValue === 1"
26
+ @click="goToPage(currentPageValue - 1)"
14
27
  title="Previous page"
15
28
  >
16
29
  <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
@@ -19,11 +32,10 @@
19
32
  </button>
20
33
 
21
34
 
22
- <div class="pagination-pages">
23
35
  <button
24
36
  v-if="showFirstPage"
25
- class="pagination-btn"
26
- :class="{ active: currentPage === 1 }"
37
+ class="button"
38
+ :class="{ active: currentPageValue === 1 }"
27
39
  @click="goToPage(1)"
28
40
  >
29
41
  1
@@ -34,8 +46,8 @@
34
46
  <button
35
47
  v-for="page in visiblePages"
36
48
  :key="page"
37
- class="pagination-btn"
38
- :class="{ active: currentPage === page }"
49
+ class="button"
50
+ :class="{ active: currentPageValue === page }"
39
51
  @click="goToPage(page)"
40
52
  >
41
53
  {{ page }}
@@ -45,18 +57,17 @@
45
57
 
46
58
  <button
47
59
  v-if="showLastPage"
48
- class="pagination-btn"
49
- :class="{ active: currentPage === totalPages }"
60
+ class="button"
61
+ :class="{ active: currentPageValue === totalPages }"
50
62
  @click="goToPage(totalPages)"
51
63
  >
52
- {{ totalPages }}
53
- </button>
54
- </div>
64
+ {{ totalPages }}
65
+ </button>
55
66
 
56
67
  <button
57
- class="pagination-btn"
58
- :disabled="currentPage === totalPages"
59
- @click="goToPage(currentPage + 1)"
68
+ class="button"
69
+ :disabled="currentPageValue === totalPages"
70
+ @click="goToPage(currentPageValue + 1)"
60
71
  title="Next page"
61
72
  >
62
73
  <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
@@ -64,21 +75,6 @@
64
75
  </svg>
65
76
  </button>
66
77
  </div>
67
-
68
- <div class="pagination-size" v-if="canChangePageSize">
69
- <label for="page-size">Items per page:</label>
70
- <select
71
- id="page-size"
72
- v-model="localPageSize"
73
- @change="handlePageSizeChange"
74
- class="page-size-select"
75
- >
76
- <option value="10">10</option>
77
- <option value="25">25</option>
78
- <option value="50">50</option>
79
- <option value="100">100</option>
80
- </select>
81
- </div>
82
78
  </div>
83
79
  </template>
84
80
 
@@ -86,10 +82,6 @@
86
82
  import { ref, computed, watch } from 'vue'
87
83
 
88
84
  const props = defineProps({
89
- pageSize: {
90
- type: Number,
91
- default: 25
92
- },
93
85
  total: {
94
86
  type: Number,
95
87
  required: true
@@ -100,30 +92,46 @@ const props = defineProps({
100
92
  },
101
93
  canChangePageSize: {
102
94
  type: Boolean,
103
- default: false
95
+ default: true,
104
96
  },
105
97
  itemTitle: {
106
98
  type: String,
107
99
  default: 'items'
100
+ },
101
+ // Support for v-model binding
102
+ page: {
103
+ type: Number,
104
+ default: 1
105
+ },
106
+ pageSize: {
107
+ type: Number,
108
+ default: 25
108
109
  }
109
110
  })
110
111
 
111
- const emit = defineEmits(['page-change', 'page-size-change'])
112
+ const emit = defineEmits(['page-change', 'page-size-change', 'update:page', 'update:pageSize'])
112
113
 
113
114
  const localPageSize = ref(props.pageSize)
114
115
  const localCurrentPage = ref(props.currentPage)
115
116
 
117
+ // Computed property to get the current page value (supports both v-model and regular props)
118
+ const currentPageValue = computed(() => {
119
+ // When using v-model, the page prop will be reactive and change
120
+ // When using regular props, currentPage will be reactive
121
+ return props.page
122
+ })
123
+
116
124
  const totalPages = computed(() => Math.ceil(props.total / localPageSize.value))
117
125
 
118
- const startItem = computed(() => (localCurrentPage.value - 1) * localPageSize.value)
119
- const endItem = computed(() => Math.min(localCurrentPage.value * localPageSize.value, props.total))
126
+ const startItem = computed(() => (currentPageValue.value - 1) * localPageSize.value)
127
+ const endItem = computed(() => Math.min(currentPageValue.value * localPageSize.value, props.total))
120
128
 
121
129
  const maxVisiblePages = 5
122
130
  const visiblePages = computed(() => {
123
131
  const pages = []
124
132
  const halfVisible = Math.floor(maxVisiblePages / 2)
125
133
 
126
- let start = Math.max(1, localCurrentPage.value - halfVisible)
134
+ let start = Math.max(1, currentPageValue.value - halfVisible)
127
135
  let end = Math.min(totalPages.value, start + maxVisiblePages - 1)
128
136
 
129
137
  if (end - start < maxVisiblePages - 1) {
@@ -143,22 +151,29 @@ const showFirstEllipsis = computed(() => visiblePages.value[0] > 2)
143
151
  const showLastEllipsis = computed(() => visiblePages.value[visiblePages.value.length - 1] < totalPages.value - 1)
144
152
 
145
153
  function goToPage(page) {
146
- if (page >= 1 && page <= totalPages.value && page !== localCurrentPage.value) {
154
+ if (page >= 1 && page <= totalPages.value && page !== currentPageValue.value) {
147
155
  localCurrentPage.value = page
148
156
  emit('page-change', page)
157
+ emit('update:page', page)
149
158
  }
150
159
  }
151
160
 
152
161
  function handlePageSizeChange() {
153
162
  localCurrentPage.value = 1
154
163
  emit('page-size-change', localPageSize.value)
164
+ emit('update:pageSize', localPageSize.value)
155
165
  emit('page-change', 1)
166
+ emit('update:page', 1)
156
167
  }
157
168
 
158
169
  watch(() => props.currentPage, (newPage) => {
159
170
  localCurrentPage.value = newPage
160
171
  })
161
172
 
173
+ watch(() => props.page, (newPage) => {
174
+ localCurrentPage.value = newPage
175
+ })
176
+
162
177
  watch(() => props.pageSize, (newSize) => {
163
178
  localPageSize.value = newSize
164
179
  })
@@ -187,71 +202,39 @@ watch(() => props.pageSize, (newSize) => {
187
202
  gap: 0.5rem;
188
203
  }
189
204
 
190
- .pagination-pages {
191
- display: flex;
192
- align-items: center;
193
- gap: 0.25rem;
194
- }
195
-
196
- .pagination-btn {
205
+ .button {
197
206
  display: flex;
198
207
  align-items: center;
199
208
  justify-content: center;
200
209
  min-width: 2.5rem;
201
210
  height: 2.5rem;
202
- padding: 0.5rem;
203
- border: 1px solid #dee2e6;
204
- background: #fff;
205
- color: #495057;
206
- text-decoration: none;
207
- border-radius: 4px;
208
- cursor: pointer;
209
- transition: all 0.2s ease;
210
- font-size: 0.875rem;
211
- }
212
-
213
- .pagination-btn:hover:not(:disabled) {
214
- background: #e9ecef;
215
- border-color: #adb5bd;
216
- color: #495057;
217
- }
218
-
219
- .pagination-btn.active {
220
- background: #c6d0d7;
221
- color: #333;
222
211
  }
223
212
 
224
- .pagination-btn:disabled {
213
+ .button:disabled {
225
214
  opacity: 0.5;
215
+ background: transparent;
226
216
  cursor: not-allowed;
227
217
  }
228
218
 
219
+ .button.active {
220
+ background: #545f69;
221
+ color: #fff;
222
+ }
223
+
229
224
  .pagination-ellipsis {
230
225
  padding: 0.5rem;
231
226
  color: #6c757d;
232
227
  font-size: 0.875rem;
233
228
  }
234
229
 
235
- .pagination-size {
236
- display: flex;
237
- align-items: center;
238
- gap: 0.5rem;
239
- font-size: 0.875rem;
240
- color: #6c757d;
241
- }
242
-
243
- .page-size-select {
244
- padding: 0.25rem 0.5rem;
245
- border: 1px solid #dee2e6;
246
- border-radius: 4px;
247
- background: #fff;
248
- font-size: 0.875rem;
230
+ #page-size {
231
+ background: transparent;
232
+ margin-left: 0.5rem;
233
+ margin-right: 0.5rem;
249
234
  }
250
235
 
251
- .page-size-select:focus {
252
- outline: none;
253
- border-color: #5681af;
254
- box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
236
+ option {
237
+ background: #545f69;
255
238
  }
256
239
 
257
240
  /* Responsive design */
package/vue/views/App.vue CHANGED
@@ -45,6 +45,7 @@
45
45
  onMounted(() => {
46
46
  sidebar.value.addRouterLink('Welcome')
47
47
  sidebar.value.addRouterLink('TableExample')
48
+ sidebar.value.addRouterLink('CalendarExample')
48
49
  sidebar.value.addSeparator('separator-1');
49
50
  sidebar.value.addRouterLink('ViewItem', { id: 1 })
50
51
  sidebar.value.addSeparator('separator-2');
@@ -0,0 +1,328 @@
1
+ <template>
2
+ <Section
3
+ title="Example Calendar"
4
+ padding
5
+ >
6
+ <template #toolbar>
7
+ <button @click="goToToday">Today</button>
8
+ <button @click="previousMonth">‹</button>
9
+ <div class="date-picker-container">
10
+ <input
11
+ id="goto-date"
12
+ type="month"
13
+ v-model="selectedDate"
14
+ @change="goToSelectedDate"
15
+ class="date-picker-input"
16
+ />
17
+ </div>
18
+ <button @click="nextMonth">›</button>
19
+ </template>
20
+ <Calendar
21
+ :section="true"
22
+ :events="events"
23
+ :loading="loading"
24
+ :error="error"
25
+ :show-navigation="false"
26
+ :current-month="currentMonthIndex"
27
+ :current-year="currentYear"
28
+ @event-click="handleEventClick"
29
+ @date-click="handleDateClick"
30
+ @month-change="handleMonthChange"
31
+ />
32
+ </Section>
33
+ </template>
34
+
35
+ <script setup>
36
+ import { ref, onMounted } from 'vue'
37
+ import Calendar from '../components/Calendar.vue'
38
+ import Section from '../components/Section.vue'
39
+
40
+ const events = ref([])
41
+ const loading = ref(false)
42
+ const error = ref(null)
43
+ const currentMonth = ref('?')
44
+ const currentMonthIndex = ref(new Date().getMonth())
45
+ const currentYear = ref(new Date().getFullYear())
46
+
47
+ // Date picker state - using native HTML5 month input
48
+ const selectedDate = ref('')
49
+
50
+ // Generate dummy events for the current month
51
+ function generateDummyEvents() {
52
+ const now = new Date()
53
+ const currentMonth = now.getMonth()
54
+ const currentYear = now.getFullYear()
55
+
56
+ // Get the number of days in the current month
57
+ const daysInMonth = new Date(currentYear, currentMonth + 1, 0).getDate()
58
+
59
+ const dummyEvents = []
60
+
61
+ // Add some recurring events
62
+ for (let day = 1; day <= daysInMonth; day++) {
63
+ const date = new Date(currentYear, currentMonth, day)
64
+ const dayOfWeek = date.getDay()
65
+
66
+ // Add weekend events (Saturday and Sunday)
67
+ if (dayOfWeek === 0 || dayOfWeek === 6) {
68
+ dummyEvents.push({
69
+ id: `weekend-${day}`,
70
+ title: 'Weekend Activity',
71
+ date: new Date(currentYear, currentMonth, day, 10, 0),
72
+ description: 'Relaxing weekend activity'
73
+ })
74
+ }
75
+
76
+ // Add some random events
77
+ if (Math.random() > 0.7) {
78
+ const eventTypes = [
79
+ { title: 'Team Meeting', time: [9, 0], duration: 1 },
80
+ { title: 'Client Call', time: [14, 30], duration: 0.5 },
81
+ { title: 'Project Review', time: [16, 0], duration: 2 },
82
+ { title: 'Lunch Break', time: [12, 0], duration: 1 },
83
+ { title: 'Code Review', time: [10, 0], duration: 1.5 }
84
+ ]
85
+
86
+ const eventType = eventTypes[Math.floor(Math.random() * eventTypes.length)]
87
+ const [hours, minutes] = eventType.time
88
+
89
+ dummyEvents.push({
90
+ id: `event-${day}-${Math.random().toString(36).substr(2, 9)}`,
91
+ title: eventType.title,
92
+ date: new Date(currentYear, currentMonth, day, hours, minutes),
93
+ description: `Scheduled ${eventType.title.toLowerCase()}`
94
+ })
95
+ }
96
+ }
97
+
98
+ // Add some multi-day events
99
+ dummyEvents.push({
100
+ id: 'conference-1',
101
+ title: 'Tech Conference',
102
+ startDate: new Date(currentYear, currentMonth, 5, 9, 0),
103
+ endDate: new Date(currentYear, currentMonth, 7, 17, 0),
104
+ description: 'Annual technology conference'
105
+ })
106
+
107
+ dummyEvents.push({
108
+ id: 'vacation-1',
109
+ title: 'Vacation',
110
+ startDate: new Date(currentYear, currentMonth, 15, 0, 0),
111
+ endDate: new Date(currentYear, currentMonth, 18, 23, 59),
112
+ description: 'Family vacation time'
113
+ })
114
+
115
+ // Add some all-day events
116
+ dummyEvents.push({
117
+ id: 'holiday-1',
118
+ title: 'Public Holiday',
119
+ date: new Date(currentYear, currentMonth, 1, 0, 0),
120
+ description: 'National holiday - office closed'
121
+ })
122
+
123
+ if (daysInMonth >= 25) {
124
+ dummyEvents.push({
125
+ id: 'deadline-1',
126
+ title: 'Project Deadline',
127
+ date: new Date(currentYear, currentMonth, 25, 0, 0),
128
+ description: 'Important project deadline'
129
+ })
130
+ }
131
+
132
+ return dummyEvents
133
+ }
134
+
135
+ function handleEventClick(event) {
136
+ console.log('Event clicked:', event)
137
+ alert(`Event: ${event.title}\nDescription: ${event.description || 'No description'}`)
138
+ }
139
+
140
+ function handleDateClick(date) {
141
+ console.log('Date clicked:', date)
142
+ alert(`Date selected: ${date.toLocaleDateString()}`)
143
+ }
144
+
145
+ // Helper function to update the date picker value
146
+ function updateDatePicker(monthIndex, year) {
147
+ const month = String(monthIndex + 1).padStart(2, '0')
148
+ selectedDate.value = `${year}-${month}`
149
+ }
150
+
151
+ // Navigation functions - using the same fast path as internal calendar navigation
152
+ function previousMonth() {
153
+ const newMonth = currentMonthIndex.value === 0 ? 11 : currentMonthIndex.value - 1
154
+ const newYear = currentMonthIndex.value === 0 ? currentYear.value - 1 : currentYear.value
155
+
156
+ // Update state directly (fast path)
157
+ currentMonthIndex.value = newMonth
158
+ currentYear.value = newYear
159
+
160
+ // Update title
161
+ const date = new Date(newYear, newMonth, 1)
162
+ currentMonth.value = date.toLocaleDateString('en-US', {
163
+ month: 'long',
164
+ year: 'numeric'
165
+ })
166
+
167
+ // Update date picker
168
+ updateDatePicker(newMonth, newYear)
169
+ }
170
+
171
+ function nextMonth() {
172
+ const newMonth = currentMonthIndex.value === 11 ? 0 : currentMonthIndex.value + 1
173
+ const newYear = currentMonthIndex.value === 11 ? currentYear.value + 1 : currentYear.value
174
+
175
+ // Update state directly (fast path)
176
+ currentMonthIndex.value = newMonth
177
+ currentYear.value = newYear
178
+
179
+ // Update title
180
+ const date = new Date(newYear, newMonth, 1)
181
+ currentMonth.value = date.toLocaleDateString('en-US', {
182
+ month: 'long',
183
+ year: 'numeric'
184
+ })
185
+
186
+ // Update date picker
187
+ updateDatePicker(newMonth, newYear)
188
+ }
189
+
190
+ function goToToday() {
191
+ const now = new Date()
192
+ const newMonth = now.getMonth()
193
+ const newYear = now.getFullYear()
194
+
195
+ // Update state directly (fast path)
196
+ currentMonthIndex.value = newMonth
197
+ currentYear.value = newYear
198
+
199
+ // Update title
200
+ currentMonth.value = now.toLocaleDateString('en-US', {
201
+ month: 'long',
202
+ year: 'numeric'
203
+ })
204
+
205
+ // Update date picker
206
+ updateDatePicker(newMonth, newYear)
207
+ }
208
+
209
+ function goToSelectedDate() {
210
+ if (!selectedDate.value) return
211
+
212
+ // Parse the YYYY-MM format from the native date picker
213
+ const [year, month] = selectedDate.value.split('-').map(Number)
214
+ const monthIndex = month - 1 // Convert to 0-based month index
215
+
216
+ // Update state directly (fast path)
217
+ currentMonthIndex.value = monthIndex
218
+ currentYear.value = year
219
+
220
+ // Update title
221
+ const date = new Date(year, monthIndex, 1)
222
+ currentMonth.value = date.toLocaleDateString('en-US', {
223
+ month: 'long',
224
+ year: 'numeric'
225
+ })
226
+ }
227
+
228
+ function handleMonthChange(month, year) {
229
+ console.log('Month changed:', month, year)
230
+ // In a real app, you might fetch events for the new month
231
+ loading.value = true
232
+
233
+ // Update current month and year state
234
+ currentMonthIndex.value = month
235
+ currentYear.value = year
236
+
237
+ // Use native JavaScript to format month and year
238
+ const date = new Date(year, month, 1)
239
+ currentMonth.value = date.toLocaleDateString('en-US', {
240
+ month: 'long',
241
+ year: 'numeric'
242
+ })
243
+
244
+ // Update date picker to reflect the new month
245
+ updateDatePicker(month, year)
246
+
247
+ setTimeout(() => {
248
+ // Simulate loading delay
249
+ events.value = generateDummyEvents()
250
+ loading.value = false
251
+ }, 500)
252
+ }
253
+
254
+ onMounted(() => {
255
+ // Generate initial events for current month
256
+ events.value = generateDummyEvents()
257
+
258
+ // Set initial month title using native JavaScript
259
+ const now = new Date()
260
+ currentMonthIndex.value = now.getMonth()
261
+ currentYear.value = now.getFullYear()
262
+ currentMonth.value = now.toLocaleDateString('en-US', {
263
+ month: 'long',
264
+ year: 'numeric'
265
+ })
266
+
267
+ // Initialize the native date picker with current month/year
268
+ const year = now.getFullYear()
269
+ const month = String(now.getMonth() + 1).padStart(2, '0')
270
+ selectedDate.value = `${year}-${month}`
271
+ })
272
+ </script>
273
+
274
+ <style scoped>
275
+ .date-picker-container {
276
+ display: flex;
277
+ align-items: center;
278
+ gap: 0.5rem;
279
+ }
280
+
281
+ .date-picker-label {
282
+ font-size: 0.875rem;
283
+ font-weight: 500;
284
+ color: #374151;
285
+ }
286
+
287
+ .date-picker-input {
288
+ padding: 0.5rem;
289
+ border: 1px solid #d1d5db;
290
+ border-radius: 4px;
291
+ font-size: 0.875rem;
292
+ background: white;
293
+ cursor: pointer;
294
+ transition: all 0.2s;
295
+ }
296
+
297
+ .date-picker-input:hover {
298
+ border-color: #9ca3af;
299
+ }
300
+
301
+ .date-picker-input:focus {
302
+ outline: none;
303
+ border-color: #3b82f6;
304
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
305
+ }
306
+
307
+ /* Dark theme support */
308
+ @media (prefers-color-scheme: dark) {
309
+ .date-picker-label {
310
+ color: #d1d5db;
311
+ }
312
+
313
+ .date-picker-input {
314
+ background: #374151;
315
+ border-color: #4b5563;
316
+ color: #f9fafb;
317
+ }
318
+
319
+ .date-picker-input:hover {
320
+ border-color: #6b7280;
321
+ }
322
+
323
+ .date-picker-input:focus {
324
+ border-color: #60a5fa;
325
+ box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.1);
326
+ }
327
+ }
328
+ </style>
@@ -11,6 +11,7 @@
11
11
  <Table
12
12
  :data = "data"
13
13
  :headers = "headers"
14
+ :show-pagination = "true"
14
15
  >
15
16
  <template #cell-name="{ row, value }">
16
17
  <router-link :to="{ name: 'ViewItem', params: { id: row.name } }">
@@ -59,7 +60,61 @@
59
60
  { name: "Quinn", age: 23, city: "Indianapolis" },
60
61
  { name: "Rita", age: 37, city: "Seattle" },
61
62
  { name: "Sam", age: 39, city: "Denver" },
62
- { name: "Tina", age: 21, city: "Washington D.C." }
63
+ { name: "Tina", age: 21, city: "Washington D.C." },
64
+ { name: "Uma", age: 28, city: "Miami" },
65
+ { name: "Victor", age: 30, city: "Portland" },
66
+ { name: "Wendy", age: 29, city: "Nashville" },
67
+ { name: "Xavier", age: 31, city: "Oklahoma City" },
68
+ { name: "Yara", age: 27, city: "Tampa" },
69
+ { name: "Zane", age: 32, city: "Albuquerque" },
70
+ { name: "Ava", age: 26, city: "Las Vegas" },
71
+ { name: "Ben", age: 33, city: "New Orleans" },
72
+ { name: "Cara", age: 25, city: "San Francisco" },
73
+ { name: "Drew", age: 34, city: "Seattle" },
74
+ { name: "Ethan", age: 28, city: "Denver" },
75
+ { name: "Fiona", age: 30, city: "Washington D.C." },
76
+ { name: "George", age: 29, city: "Miami" },
77
+ { name: "Hannah", age: 32, city: "San Diego" },
78
+ { name: "Ian", age: 27, city: "Dallas" },
79
+ { name: "Jack", age: 31, city: "San Jose" },
80
+ { name: "Kathy", age: 26, city: "Austin" },
81
+ { name: "Leo", age: 33, city: "Jacksonville" },
82
+ { name: "Mia", age: 24, city: "Fort Worth" },
83
+ { name: "Nina", age: 38, city: "Columbus" },
84
+ { name: "Oscar", age: 36, city: "Charlotte" },
85
+ { name: "Paul", age: 34, city: "San Francisco" },
86
+ { name: "Quinn", age: 23, city: "Indianapolis" },
87
+ { name: "Rita", age: 37, city: "Seattle" },
88
+ { name: "Sam", age: 39, city: "Denver" },
89
+ { name: "Tina", age: 21, city: "Washington D.C." },
90
+ { name: "Uma", age: 28, city: "Miami" },
91
+ { name: "Victor", age: 30, city: "Portland" },
92
+ { name: "Wendy", age: 29, city: "Nashville" },
93
+ { name: "Xavier", age: 31, city: "Oklahoma City" },
94
+ { name: "Yara", age: 27, city: "Tampa" },
95
+ { name: "Zane", age: 32, city: "Albuquerque" },
96
+ { name: "Ava", age: 26, city: "Las Vegas" },
97
+ { name: "Ben", age: 33, city: "New Orleans" },
98
+ { name: "Cara", age: 25, city: "San Francisco" },
99
+ { name: "Drew", age: 34, city: "Seattle" },
100
+ { name: "Ethan", age: 28, city: "Denver" },
101
+ { name: "Fiona", age: 30, city: "Washington D.C." },
102
+ { name: "George", age: 29, city: "Miami" },
103
+ { name: "Hannah", age: 32, city: "San Diego" },
104
+ { name: "Ian", age: 27, city: "Dallas" },
105
+ { name: "Jack", age: 31, city: "San Jose" },
106
+ { name: "Kathy", age: 26, city: "Austin" },
107
+ { name: "Leo", age: 33, city: "Jacksonville" },
108
+ { name: "Mia", age: 24, city: "Fort Worth" },
109
+ { name: "Nina", age: 38, city: "Columbus" },
110
+ { name: "Oscar", age: 36, city: "Charlotte" },
111
+ { name: "Paul", age: 34, city: "San Francisco" },
112
+ { name: "Quinn", age: 23, city: "Indianapolis" },
113
+ { name: "Rita", age: 37, city: "Seattle" },
114
+ { name: "Sam", age: 39, city: "Denver" },
115
+ { name: "Tina", age: 21, city: "Washington D.C." },
116
+ { name: "Uma", age: 28, city: "Miami" },
117
+ { name: "Victor", age: 30, city: "Portland" },
63
118
  ]
64
119
  })
65
120
  </script>