@object-ui/plugin-calendar 0.5.0 → 3.0.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.
@@ -40,11 +40,13 @@ export interface CalendarViewProps {
40
40
  events?: CalendarEvent[]
41
41
  view?: "month" | "week" | "day"
42
42
  currentDate?: Date
43
+ locale?: string
43
44
  onEventClick?: (event: CalendarEvent) => void
44
45
  onDateClick?: (date: Date) => void
45
46
  onViewChange?: (view: "month" | "week" | "day") => void
46
47
  onNavigate?: (date: Date) => void
47
48
  onAddClick?: () => void
49
+ onEventDrop?: (event: CalendarEvent, newStart: Date, newEnd?: Date) => void
48
50
  className?: string
49
51
  }
50
52
 
@@ -52,11 +54,13 @@ function CalendarView({
52
54
  events = [],
53
55
  view = "month",
54
56
  currentDate = new Date(),
57
+ locale = "default",
55
58
  onEventClick,
56
59
  onDateClick,
57
60
  onViewChange,
58
61
  onNavigate,
59
62
  onAddClick,
63
+ onEventDrop,
60
64
  className,
61
65
  }: CalendarViewProps) {
62
66
  const [selectedView, setSelectedView] = React.useState(view)
@@ -71,6 +75,23 @@ function CalendarView({
71
75
  setSelectedView(view)
72
76
  }, [view])
73
77
 
78
+ // Auto-switch to day view on mobile
79
+ const onViewChangeRef = React.useRef(onViewChange)
80
+ onViewChangeRef.current = onViewChange
81
+
82
+ React.useEffect(() => {
83
+ const mq = window.matchMedia("(max-width: 639px)")
84
+ const handleChange = (e: MediaQueryListEvent | MediaQueryList) => {
85
+ if (e.matches) {
86
+ setSelectedView("day")
87
+ onViewChangeRef.current?.("day")
88
+ }
89
+ }
90
+ handleChange(mq)
91
+ mq.addEventListener("change", handleChange)
92
+ return () => mq.removeEventListener("change", handleChange)
93
+ }, [])
94
+
74
95
  const handlePrevious = () => {
75
96
  const newDate = new Date(selectedDate)
76
97
  if (selectedView === "month") {
@@ -110,7 +131,7 @@ function CalendarView({
110
131
 
111
132
  const getDateLabel = () => {
112
133
  if (selectedView === "month") {
113
- return selectedDate.toLocaleDateString("default", {
134
+ return selectedDate.toLocaleDateString(locale, {
114
135
  month: "long",
115
136
  year: "numeric",
116
137
  })
@@ -118,16 +139,16 @@ function CalendarView({
118
139
  const weekStart = getWeekStart(selectedDate)
119
140
  const weekEnd = new Date(weekStart)
120
141
  weekEnd.setDate(weekEnd.getDate() + 6)
121
- return `${weekStart.toLocaleDateString("default", {
142
+ return `${weekStart.toLocaleDateString(locale, {
122
143
  month: "short",
123
144
  day: "numeric",
124
- })} - ${weekEnd.toLocaleDateString("default", {
145
+ })} - ${weekEnd.toLocaleDateString(locale, {
125
146
  month: "short",
126
147
  day: "numeric",
127
148
  year: "numeric",
128
149
  })}`
129
150
  } else {
130
- return selectedDate.toLocaleDateString("default", {
151
+ return selectedDate.toLocaleDateString(locale, {
131
152
  weekday: "long",
132
153
  month: "long",
133
154
  day: "numeric",
@@ -136,6 +157,23 @@ function CalendarView({
136
157
  }
137
158
  }
138
159
 
160
+ // Swipe navigation for mobile
161
+ const touchStart = React.useRef<number>(0)
162
+ const handleTouchStart = (e: React.TouchEvent) => {
163
+ touchStart.current = e.touches[0].clientX
164
+ }
165
+ const handleTouchEnd = (e: React.TouchEvent) => {
166
+ const diff = touchStart.current - e.changedTouches[0].clientX
167
+ if (Math.abs(diff) > 50) {
168
+ const newDate = new Date(selectedDate)
169
+ if (selectedView === "day") newDate.setDate(newDate.getDate() + (diff > 0 ? 1 : -1))
170
+ else if (selectedView === "week") newDate.setDate(newDate.getDate() + (diff > 0 ? 7 : -7))
171
+ else newDate.setMonth(newDate.getMonth() + (diff > 0 ? 1 : -1))
172
+ setSelectedDate(newDate)
173
+ onNavigate?.(newDate)
174
+ }
175
+ }
176
+
139
177
  const handleDateSelect = (date: Date | undefined) => {
140
178
  if (date) {
141
179
  setSelectedDate(date)
@@ -144,18 +182,19 @@ function CalendarView({
144
182
  }
145
183
 
146
184
  return (
147
- <div className={cn("flex flex-col h-full bg-background", className)}>
185
+ <div role="region" aria-label="Calendar" className={cn("flex flex-col h-full bg-background", className)}>
148
186
  {/* Header */}
149
187
  <div className="flex items-center justify-between p-4 border-b">
150
188
  <div className="flex items-center gap-4">
151
189
  <div className="flex items-center bg-muted/50 rounded-lg p-1 gap-1">
152
- <Button variant="ghost" size="sm" onClick={handleToday} className="h-8">
190
+ <Button variant="ghost" size="sm" onClick={handleToday} className="h-8" aria-label="Go to today">
153
191
  Today
154
192
  </Button>
155
193
  <div className="h-4 w-px bg-border mx-1" />
156
194
  <Button
157
195
  variant="ghost"
158
196
  size="icon"
197
+ aria-label="Previous period"
159
198
  onClick={handlePrevious}
160
199
  className="h-8 w-8"
161
200
  >
@@ -164,6 +203,7 @@ function CalendarView({
164
203
  <Button
165
204
  variant="ghost"
166
205
  size="icon"
206
+ aria-label="Next period"
167
207
  onClick={handleNext}
168
208
  className="h-8 w-8"
169
209
  >
@@ -175,6 +215,7 @@ function CalendarView({
175
215
  <PopoverTrigger asChild>
176
216
  <Button
177
217
  variant="ghost"
218
+ aria-label={`Current date: ${getDateLabel()}`}
178
219
  className={cn(
179
220
  "text-xl font-semibold h-auto px-3 py-1 hover:bg-muted/50 transition-colors",
180
221
  "flex items-center gap-2"
@@ -219,19 +260,21 @@ function CalendarView({
219
260
  </div>
220
261
 
221
262
  {/* Calendar Grid */}
222
- <div className="flex-1 overflow-auto">
263
+ <div className="flex-1 overflow-auto" onTouchStart={handleTouchStart} onTouchEnd={handleTouchEnd}>
223
264
  {selectedView === "month" && (
224
265
  <MonthView
225
266
  date={selectedDate}
226
267
  events={events}
227
268
  onEventClick={onEventClick}
228
269
  onDateClick={onDateClick}
270
+ onEventDrop={onEventDrop}
229
271
  />
230
272
  )}
231
273
  {selectedView === "week" && (
232
274
  <WeekView
233
275
  date={selectedDate}
234
276
  events={events}
277
+ locale={locale}
235
278
  onEventClick={onEventClick}
236
279
  onDateClick={onDateClick}
237
280
  />
@@ -241,6 +284,7 @@ function CalendarView({
241
284
  date={selectedDate}
242
285
  events={events}
243
286
  onEventClick={onEventClick}
287
+ onDateClick={onDateClick}
244
288
  />
245
289
  )}
246
290
  </div>
@@ -320,20 +364,79 @@ interface MonthViewProps {
320
364
  events: CalendarEvent[]
321
365
  onEventClick?: (event: CalendarEvent) => void
322
366
  onDateClick?: (date: Date) => void
367
+ onEventDrop?: (event: CalendarEvent, newStart: Date, newEnd?: Date) => void
323
368
  }
324
369
 
325
- function MonthView({ date, events, onEventClick, onDateClick }: MonthViewProps) {
370
+ function MonthView({ date, events, onEventClick, onDateClick, onEventDrop }: MonthViewProps) {
326
371
  const days = getMonthDays(date)
327
372
  const today = new Date()
328
373
  const weekDays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
374
+ const [draggedEventId, setDraggedEventId] = React.useState<string | number | null>(null)
375
+ const [dropTargetIndex, setDropTargetIndex] = React.useState<number | null>(null)
376
+
377
+ const handleDragStart = (e: React.DragEvent, event: CalendarEvent) => {
378
+ setDraggedEventId(event.id)
379
+ e.dataTransfer.effectAllowed = "move"
380
+ e.dataTransfer.setData("text/plain", String(event.id))
381
+ }
382
+
383
+ const handleDragEnd = () => {
384
+ setDraggedEventId(null)
385
+ setDropTargetIndex(null)
386
+ }
387
+
388
+ const handleDragOver = (e: React.DragEvent, index: number) => {
389
+ e.preventDefault()
390
+ e.dataTransfer.dropEffect = "move"
391
+ setDropTargetIndex(index)
392
+ }
393
+
394
+ const handleDragLeave = (e: React.DragEvent) => {
395
+ // Only clear when actually leaving the cell, not when moving over child elements
396
+ if (!e.currentTarget.contains(e.relatedTarget as Node)) {
397
+ setDropTargetIndex(null)
398
+ }
399
+ }
400
+
401
+ const handleDrop = (e: React.DragEvent, targetDay: Date) => {
402
+ e.preventDefault()
403
+ setDropTargetIndex(null)
404
+ setDraggedEventId(null)
405
+
406
+ if (!onEventDrop) return
407
+
408
+ const eventId = e.dataTransfer.getData("text/plain")
409
+ const draggedEvent = events.find((ev) => String(ev.id) === eventId)
410
+ if (!draggedEvent) return
411
+
412
+ const oldStart = new Date(draggedEvent.start)
413
+ const oldStartDay = new Date(oldStart)
414
+ oldStartDay.setHours(0, 0, 0, 0)
415
+
416
+ const newTargetDay = new Date(targetDay)
417
+ newTargetDay.setHours(0, 0, 0, 0)
418
+
419
+ const deltaMs = newTargetDay.getTime() - oldStartDay.getTime()
420
+ if (deltaMs === 0) return
421
+
422
+ const newStart = new Date(oldStart.getTime() + deltaMs)
423
+
424
+ let newEnd: Date | undefined
425
+ if (draggedEvent.end) {
426
+ newEnd = new Date(new Date(draggedEvent.end).getTime() + deltaMs)
427
+ }
428
+
429
+ onEventDrop(draggedEvent, newStart, newEnd)
430
+ }
329
431
 
330
432
  return (
331
433
  <div className="flex flex-col h-full">
332
434
  {/* Week day headers */}
333
- <div className="grid grid-cols-7 border-b">
435
+ <div role="row" className="grid grid-cols-7 border-b">
334
436
  {weekDays.map((day) => (
335
437
  <div
336
438
  key={day}
439
+ role="columnheader"
337
440
  className="p-2 text-center text-sm font-medium text-muted-foreground border-r last:border-r-0"
338
441
  >
339
442
  {day}
@@ -342,7 +445,7 @@ function MonthView({ date, events, onEventClick, onDateClick }: MonthViewProps)
342
445
  </div>
343
446
 
344
447
  {/* Calendar days */}
345
- <div className="grid grid-cols-7 flex-1 auto-rows-fr">
448
+ <div role="grid" aria-label="Calendar grid" className="grid grid-cols-7 flex-1 auto-rows-fr">
346
449
  {days.map((day, index) => {
347
450
  const dayEvents = getEventsForDate(day, events)
348
451
  const isCurrentMonth = day.getMonth() === date.getMonth()
@@ -351,11 +454,17 @@ function MonthView({ date, events, onEventClick, onDateClick }: MonthViewProps)
351
454
  return (
352
455
  <div
353
456
  key={index}
457
+ role="gridcell"
458
+ aria-label={`${day.toLocaleDateString("default", { weekday: "long", month: "long", day: "numeric", year: "numeric" })}${dayEvents.length > 0 ? `, ${dayEvents.length} event${dayEvents.length > 1 ? "s" : ""}` : ""}`}
354
459
  className={cn(
355
460
  "border-b border-r last:border-r-0 p-2 min-h-[100px] cursor-pointer hover:bg-accent/50",
356
- !isCurrentMonth && "bg-muted/30 text-muted-foreground"
461
+ !isCurrentMonth && "bg-muted/30 text-muted-foreground",
462
+ dropTargetIndex === index && "ring-2 ring-primary"
357
463
  )}
358
464
  onClick={() => onDateClick?.(day)}
465
+ onDragOver={(e) => handleDragOver(e, index)}
466
+ onDragLeave={handleDragLeave}
467
+ onDrop={(e) => handleDrop(e, day)}
359
468
  >
360
469
  <div
361
470
  className={cn(
@@ -363,6 +472,7 @@ function MonthView({ date, events, onEventClick, onDateClick }: MonthViewProps)
363
472
  isToday &&
364
473
  "inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground h-6 w-6"
365
474
  )}
475
+ {...(isToday ? { "aria-current": "date" as const } : {})}
366
476
  >
367
477
  {day.getDate()}
368
478
  </div>
@@ -370,9 +480,15 @@ function MonthView({ date, events, onEventClick, onDateClick }: MonthViewProps)
370
480
  {dayEvents.slice(0, 3).map((event) => (
371
481
  <div
372
482
  key={event.id}
483
+ role="button"
484
+ aria-label={event.title}
485
+ draggable={!!onEventDrop}
486
+ onDragStart={(e) => handleDragStart(e, event)}
487
+ onDragEnd={handleDragEnd}
373
488
  className={cn(
374
489
  "text-xs px-2 py-1 rounded truncate cursor-pointer hover:opacity-80",
375
- event.color || DEFAULT_EVENT_COLOR
490
+ event.color || DEFAULT_EVENT_COLOR,
491
+ draggedEventId === event.id && "opacity-50"
376
492
  )}
377
493
  style={
378
494
  event.color && event.color.startsWith("#")
@@ -404,11 +520,28 @@ function MonthView({ date, events, onEventClick, onDateClick }: MonthViewProps)
404
520
  interface WeekViewProps {
405
521
  date: Date
406
522
  events: CalendarEvent[]
523
+ locale?: string
407
524
  onEventClick?: (event: CalendarEvent) => void
408
525
  onDateClick?: (date: Date) => void
409
526
  }
410
527
 
411
- function WeekView({ date, events, onEventClick, onDateClick }: WeekViewProps) {
528
+ function WeekView({ date, events, locale = "default", onEventClick, onDateClick }: WeekViewProps) {
529
+ const longPressTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null)
530
+
531
+ const handleSlotTouchStart = (day: Date) => {
532
+ if (!onDateClick) return
533
+ longPressTimer.current = setTimeout(() => {
534
+ onDateClick(day)
535
+ }, 500)
536
+ }
537
+
538
+ const handleSlotTouchEnd = () => {
539
+ if (longPressTimer.current) {
540
+ clearTimeout(longPressTimer.current)
541
+ longPressTimer.current = null
542
+ }
543
+ }
544
+
412
545
  const weekStart = getWeekStart(date)
413
546
  const weekDays = Array.from({ length: 7 }, (_, i) => {
414
547
  const day = new Date(weekStart)
@@ -429,7 +562,7 @@ function WeekView({ date, events, onEventClick, onDateClick }: WeekViewProps) {
429
562
  className="p-3 text-center border-r last:border-r-0"
430
563
  >
431
564
  <div className="text-sm font-medium text-muted-foreground">
432
- {day.toLocaleDateString("default", { weekday: "short" })}
565
+ {day.toLocaleDateString(locale, { weekday: "short" })}
433
566
  </div>
434
567
  <div
435
568
  className={cn(
@@ -446,21 +579,27 @@ function WeekView({ date, events, onEventClick, onDateClick }: WeekViewProps) {
446
579
  </div>
447
580
 
448
581
  {/* Week events */}
449
- <div className="grid grid-cols-7 flex-1">
582
+ <div role="grid" className="grid grid-cols-7 flex-1">
450
583
  {weekDays.map((day) => {
451
584
  const dayEvents = getEventsForDate(day, events)
452
585
  return (
453
586
  <div
454
587
  key={day.toISOString()}
588
+ role="gridcell"
589
+ aria-label={`${day.toLocaleDateString("default", { weekday: "long", month: "long", day: "numeric", year: "numeric" })}${dayEvents.length > 0 ? `, ${dayEvents.length} event${dayEvents.length > 1 ? "s" : ""}` : ""}`}
455
590
  className="border-r last:border-r-0 p-2 min-h-[400px] cursor-pointer hover:bg-accent/50"
456
591
  onClick={() => onDateClick?.(day)}
592
+ onTouchStart={() => handleSlotTouchStart(day)}
593
+ onTouchEnd={handleSlotTouchEnd}
457
594
  >
458
595
  <div className="space-y-2">
459
596
  {dayEvents.map((event) => (
460
597
  <div
461
598
  key={event.id}
599
+ role="button"
600
+ aria-label={event.title}
462
601
  className={cn(
463
- "text-sm px-3 py-2 rounded cursor-pointer hover:opacity-80",
602
+ "text-xs sm:text-sm px-2 sm:px-3 py-1.5 sm:py-2 rounded cursor-pointer hover:opacity-80",
464
603
  event.color || DEFAULT_EVENT_COLOR
465
604
  )}
466
605
  style={
@@ -473,7 +612,7 @@ function WeekView({ date, events, onEventClick, onDateClick }: WeekViewProps) {
473
612
  onEventClick?.(event)
474
613
  }}
475
614
  >
476
- <div className="font-medium">{event.title}</div>
615
+ <div className="font-medium truncate">{event.title}</div>
477
616
  {!event.allDay && (
478
617
  <div className="text-xs opacity-90 mt-1">
479
618
  {event.start.toLocaleTimeString("default", {
@@ -497,15 +636,33 @@ interface DayViewProps {
497
636
  date: Date
498
637
  events: CalendarEvent[]
499
638
  onEventClick?: (event: CalendarEvent) => void
639
+ onDateClick?: (date: Date) => void
500
640
  }
501
641
 
502
- function DayView({ date, events, onEventClick }: DayViewProps) {
642
+ function DayView({ date, events, onEventClick, onDateClick }: DayViewProps) {
503
643
  const dayEvents = getEventsForDate(date, events)
504
644
  const hours = Array.from({ length: 24 }, (_, i) => i)
645
+ const longPressTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null)
646
+
647
+ const handleSlotTouchStart = (hour: number) => {
648
+ if (!onDateClick) return
649
+ longPressTimer.current = setTimeout(() => {
650
+ const clickDate = new Date(date)
651
+ clickDate.setHours(hour, 0, 0, 0)
652
+ onDateClick(clickDate)
653
+ }, 500)
654
+ }
655
+
656
+ const handleSlotTouchEnd = () => {
657
+ if (longPressTimer.current) {
658
+ clearTimeout(longPressTimer.current)
659
+ longPressTimer.current = null
660
+ }
661
+ }
505
662
 
506
663
  return (
507
664
  <div className="flex flex-col h-full">
508
- <div className="flex-1 overflow-auto">
665
+ <div role="list" className="flex-1 overflow-auto">
509
666
  {hours.map((hour) => {
510
667
  const hourEvents = dayEvents.filter((event) => {
511
668
  if (event.allDay) return hour === 0
@@ -514,7 +671,7 @@ function DayView({ date, events, onEventClick }: DayViewProps) {
514
671
  })
515
672
 
516
673
  return (
517
- <div key={hour} className="flex border-b min-h-[60px]">
674
+ <div key={hour} role="listitem" className="flex border-b min-h-[60px]">
518
675
  <div className="w-20 p-2 text-sm text-muted-foreground border-r">
519
676
  {hour === 0
520
677
  ? "12 AM"
@@ -524,12 +681,17 @@ function DayView({ date, events, onEventClick }: DayViewProps) {
524
681
  ? "12 PM"
525
682
  : `${hour - 12} PM`}
526
683
  </div>
527
- <div className="flex-1 p-2 space-y-2">
684
+ <div
685
+ className="flex-1 p-2 space-y-2"
686
+ onTouchStart={() => handleSlotTouchStart(hour)}
687
+ onTouchEnd={handleSlotTouchEnd}
688
+ >
528
689
  {hourEvents.map((event) => (
529
690
  <div
530
691
  key={event.id}
692
+ aria-label={event.title}
531
693
  className={cn(
532
- "px-3 py-2 rounded cursor-pointer hover:opacity-80",
694
+ "px-2 sm:px-3 py-1.5 sm:py-2 rounded cursor-pointer hover:opacity-80",
533
695
  event.color || DEFAULT_EVENT_COLOR
534
696
  )}
535
697
  style={
@@ -539,7 +701,7 @@ function DayView({ date, events, onEventClick }: DayViewProps) {
539
701
  }
540
702
  onClick={() => onEventClick?.(event)}
541
703
  >
542
- <div className="font-medium">{event.title}</div>
704
+ <div className="font-medium truncate">{event.title}</div>
543
705
  {!event.allDay && (
544
706
  <div className="text-xs opacity-90 mt-1">
545
707
  {event.start.toLocaleTimeString("default", {
@@ -11,19 +11,23 @@ const BASE_URL = 'http://localhost';
11
11
 
12
12
  // --- Mock Data ---
13
13
 
14
+ // Create a stable date at noon to avoid day-spanning issues when tests run late at night
15
+ const todayAtNoon = new Date();
16
+ todayAtNoon.setHours(12, 0, 0, 0);
17
+
14
18
  const mockEvents = {
15
19
  value: [
16
20
  {
17
21
  _id: '1',
18
22
  title: 'Meeting with Client',
19
- start: new Date().toISOString(),
20
- end: new Date(Date.now() + 3600000).toISOString(),
23
+ start: todayAtNoon.toISOString(),
24
+ end: new Date(todayAtNoon.getTime() + 3600000).toISOString(),
21
25
  type: 'business'
22
26
  },
23
27
  {
24
28
  _id: '2',
25
29
  title: 'Team Lunch',
26
- start: new Date(Date.now() + 86400000).toISOString(), // Tomorrow
30
+ start: new Date(todayAtNoon.getTime() + 86400000).toISOString(), // Tomorrow
27
31
  type: 'personal'
28
32
  }
29
33
  ]
@@ -46,7 +50,7 @@ const handlers = [
46
50
  }),
47
51
 
48
52
  // Metadata Query
49
- http.get(`${BASE_URL}/api/v1/meta/object/events`, () => {
53
+ http.get(`${BASE_URL}/api/v1/metadata/object/events`, () => {
50
54
  return HttpResponse.json({ fields: {} });
51
55
  })
52
56
  ];
@@ -0,0 +1,82 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react';
3
+ import type { BaseSchema } from '@object-ui/types';
4
+ import { createStorybookDataSource } from '@storybook-config/datasource';
5
+
6
+ const meta = {
7
+ title: 'Plugins/ObjectCalendar',
8
+ component: SchemaRenderer,
9
+ parameters: {
10
+ layout: 'padded',
11
+ },
12
+ tags: ['autodocs'],
13
+ argTypes: {
14
+ schema: { table: { disable: true } },
15
+ },
16
+ } satisfies Meta<any>;
17
+
18
+ export default meta;
19
+ type Story = StoryObj<typeof meta>;
20
+
21
+ const dataSource = createStorybookDataSource();
22
+
23
+ const renderStory = (args: any) => (
24
+ <SchemaRendererProvider dataSource={dataSource}>
25
+ <SchemaRenderer schema={args as unknown as BaseSchema} />
26
+ </SchemaRendererProvider>
27
+ );
28
+
29
+ export const Default: Story = {
30
+ render: renderStory,
31
+ args: {
32
+ type: 'object-grid',
33
+ objectName: 'Event',
34
+ viewType: 'calendar',
35
+ calendar: {
36
+ startDateField: 'startDate',
37
+ endDateField: 'endDate',
38
+ titleField: 'title',
39
+ },
40
+ columns: [
41
+ { field: 'title', header: 'Title' },
42
+ { field: 'startDate', header: 'Start Date' },
43
+ { field: 'endDate', header: 'End Date' },
44
+ { field: 'category', header: 'Category' },
45
+ ],
46
+ data: [
47
+ { id: 1, title: 'Team Standup', startDate: '2024-03-04T09:00:00', endDate: '2024-03-04T09:30:00', category: 'Meeting' },
48
+ { id: 2, title: 'Sprint Planning', startDate: '2024-03-05T10:00:00', endDate: '2024-03-05T12:00:00', category: 'Meeting' },
49
+ { id: 3, title: 'Design Review', startDate: '2024-03-06T14:00:00', endDate: '2024-03-06T15:00:00', category: 'Review' },
50
+ { id: 4, title: 'Product Demo', startDate: '2024-03-07T16:00:00', endDate: '2024-03-07T17:00:00', category: 'Demo' },
51
+ { id: 5, title: 'Retrospective', startDate: '2024-03-08T11:00:00', endDate: '2024-03-08T12:00:00', category: 'Meeting' },
52
+ ],
53
+ className: 'w-full',
54
+ } as any,
55
+ };
56
+
57
+ export const MonthlyEvents: Story = {
58
+ render: renderStory,
59
+ args: {
60
+ type: 'object-grid',
61
+ objectName: 'Appointment',
62
+ viewType: 'calendar',
63
+ calendar: {
64
+ startDateField: 'date',
65
+ titleField: 'name',
66
+ },
67
+ columns: [
68
+ { field: 'name', header: 'Name' },
69
+ { field: 'date', header: 'Date' },
70
+ { field: 'type', header: 'Type' },
71
+ ],
72
+ data: [
73
+ { id: 1, name: 'Board Meeting', date: '2024-03-01T10:00:00', type: 'Corporate' },
74
+ { id: 2, name: 'Client Call', date: '2024-03-05T14:00:00', type: 'Sales' },
75
+ { id: 3, name: 'Team Lunch', date: '2024-03-12T12:00:00', type: 'Social' },
76
+ { id: 4, name: 'Quarterly Review', date: '2024-03-15T09:00:00', type: 'Corporate' },
77
+ { id: 5, name: 'Workshop', date: '2024-03-20T13:00:00', type: 'Training' },
78
+ { id: 6, name: 'Release Day', date: '2024-03-25T08:00:00', type: 'Engineering' },
79
+ ],
80
+ className: 'w-full',
81
+ } as any,
82
+ };
@@ -25,6 +25,9 @@
25
25
  import React, { useEffect, useState, useCallback, useMemo } from 'react';
26
26
  import type { ObjectGridSchema, DataSource, ViewData, CalendarConfig } from '@object-ui/types';
27
27
  import { CalendarView, type CalendarEvent } from './CalendarView';
28
+ import { usePullToRefresh } from '@object-ui/mobile';
29
+ import { useNavigationOverlay } from '@object-ui/react';
30
+ import { NavigationOverlay } from '@object-ui/components';
28
31
 
29
32
  export interface CalendarSchema {
30
33
  type: 'calendar';
@@ -44,6 +47,7 @@ export interface ObjectCalendarProps {
44
47
  dataSource?: DataSource;
45
48
  className?: string;
46
49
  onEventClick?: (record: any) => void;
50
+ onRowClick?: (record: any) => void;
47
51
  onDateClick?: (date: Date) => void;
48
52
  onEdit?: (record: any) => void;
49
53
  onDelete?: (record: any) => void;
@@ -136,6 +140,7 @@ export const ObjectCalendar: React.FC<ObjectCalendarProps> = ({
136
140
  dataSource,
137
141
  className,
138
142
  onEventClick,
143
+ onRowClick,
139
144
  onDateClick,
140
145
  onNavigate,
141
146
  onViewChange,
@@ -147,6 +152,16 @@ export const ObjectCalendar: React.FC<ObjectCalendarProps> = ({
147
152
  const [objectSchema, setObjectSchema] = useState<any>(null);
148
153
  const [currentDate, setCurrentDate] = useState(new Date());
149
154
  const [view, setView] = useState<'month' | 'week' | 'day'>('month');
155
+ const [refreshKey, setRefreshKey] = useState(0);
156
+
157
+ const handlePullRefresh = useCallback(async () => {
158
+ setRefreshKey(k => k + 1);
159
+ }, []);
160
+
161
+ const { ref: pullRef, isRefreshing, pullDistance } = usePullToRefresh<HTMLDivElement>({
162
+ onRefresh: handlePullRefresh,
163
+ enabled: !!dataSource && !!schema.objectName,
164
+ });
150
165
 
151
166
  const dataConfig = useMemo(() => getDataConfig(schema), [
152
167
  (schema as any).data,
@@ -232,7 +247,7 @@ export const ObjectCalendar: React.FC<ObjectCalendarProps> = ({
232
247
 
233
248
  fetchData();
234
249
  return () => { isMounted = false; };
235
- }, [dataConfig, dataSource, hasInlineData, schema.filter, schema.sort]);
250
+ }, [dataConfig, dataSource, hasInlineData, schema.filter, schema.sort, refreshKey]);
236
251
 
237
252
  // Fetch object schema for field metadata
238
253
  useEffect(() => {
@@ -292,6 +307,14 @@ export const ObjectCalendar: React.FC<ObjectCalendarProps> = ({
292
307
  onDateClick?.(today);
293
308
  }, [onDateClick]);
294
309
 
310
+ // --- NavigationConfig support ---
311
+ // Must be called before any early returns to satisfy React hooks rules
312
+ const navigation = useNavigationOverlay({
313
+ navigation: (schema as any).navigation,
314
+ objectName: schema.objectName,
315
+ onRowClick,
316
+ });
317
+
295
318
  if (loading) {
296
319
  return (
297
320
  <div className={className}>
@@ -325,13 +348,24 @@ export const ObjectCalendar: React.FC<ObjectCalendarProps> = ({
325
348
  }
326
349
 
327
350
  return (
328
- <div className={className}>
329
- <div className="border rounded-lg bg-background h-[calc(100vh-200px)] min-h-[600px]">
351
+ <div ref={pullRef} className={className}>
352
+ {pullDistance > 0 && (
353
+ <div
354
+ className="flex items-center justify-center text-xs text-muted-foreground"
355
+ style={{ height: pullDistance }}
356
+ >
357
+ {isRefreshing ? 'Refreshing…' : 'Pull to refresh'}
358
+ </div>
359
+ )}
360
+ <div className="border rounded-lg bg-background h-[calc(100vh-120px)] sm:h-[calc(100vh-160px)] md:h-[calc(100vh-200px)] min-h-[400px] sm:min-h-[600px]">
330
361
  <CalendarView
331
362
  events={events}
332
363
  currentDate={currentDate}
333
364
  view={(schema as any).defaultView || 'month'}
334
- onEventClick={(event) => onEventClick?.(event.data)}
365
+ onEventClick={(event) => {
366
+ navigation.handleClick(event.data);
367
+ onEventClick?.(event.data);
368
+ }}
335
369
  onDateClick={onDateClick}
336
370
  onNavigate={(date) => {
337
371
  setCurrentDate(date);
@@ -344,6 +378,22 @@ export const ObjectCalendar: React.FC<ObjectCalendarProps> = ({
344
378
  onAddClick={handleCreate}
345
379
  />
346
380
  </div>
381
+ {navigation.isOverlay && (
382
+ <NavigationOverlay {...navigation} title="Event Details">
383
+ {(record) => (
384
+ <div className="space-y-3">
385
+ {Object.entries(record).map(([key, value]) => (
386
+ <div key={key} className="flex flex-col">
387
+ <span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
388
+ {key.replace(/_/g, ' ')}
389
+ </span>
390
+ <span className="text-sm">{String(value ?? '—')}</span>
391
+ </div>
392
+ ))}
393
+ </div>
394
+ )}
395
+ </NavigationOverlay>
396
+ )}
347
397
  </div>
348
398
  );
349
399
  };