@object-ui/plugin-calendar 2.0.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.
- package/.turbo/turbo-build.log +9 -9
- package/CHANGELOG.md +22 -0
- package/dist/index.js +633 -488
- package/dist/index.umd.cjs +2 -2
- package/dist/src/CalendarView.d.ts +2 -1
- package/dist/src/CalendarView.d.ts.map +1 -1
- package/dist/src/ObjectCalendar.d.ts +1 -0
- package/dist/src/ObjectCalendar.d.ts.map +1 -1
- package/dist/src/ObjectCalendar.stories.d.ts +23 -0
- package/dist/src/ObjectCalendar.stories.d.ts.map +1 -0
- package/package.json +11 -10
- package/src/CalendarView.tsx +178 -18
- package/src/ObjectCalendar.stories.tsx +82 -0
- package/src/ObjectCalendar.tsx +54 -4
- package/src/__tests__/accessibility.test.tsx +290 -0
- package/src/__tests__/performance-benchmark.test.tsx +227 -0
- package/src/__tests__/view-states.test.tsx +377 -0
package/src/CalendarView.tsx
CHANGED
|
@@ -46,6 +46,7 @@ export interface CalendarViewProps {
|
|
|
46
46
|
onViewChange?: (view: "month" | "week" | "day") => void
|
|
47
47
|
onNavigate?: (date: Date) => void
|
|
48
48
|
onAddClick?: () => void
|
|
49
|
+
onEventDrop?: (event: CalendarEvent, newStart: Date, newEnd?: Date) => void
|
|
49
50
|
className?: string
|
|
50
51
|
}
|
|
51
52
|
|
|
@@ -59,6 +60,7 @@ function CalendarView({
|
|
|
59
60
|
onViewChange,
|
|
60
61
|
onNavigate,
|
|
61
62
|
onAddClick,
|
|
63
|
+
onEventDrop,
|
|
62
64
|
className,
|
|
63
65
|
}: CalendarViewProps) {
|
|
64
66
|
const [selectedView, setSelectedView] = React.useState(view)
|
|
@@ -73,6 +75,23 @@ function CalendarView({
|
|
|
73
75
|
setSelectedView(view)
|
|
74
76
|
}, [view])
|
|
75
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
|
+
|
|
76
95
|
const handlePrevious = () => {
|
|
77
96
|
const newDate = new Date(selectedDate)
|
|
78
97
|
if (selectedView === "month") {
|
|
@@ -138,6 +157,23 @@ function CalendarView({
|
|
|
138
157
|
}
|
|
139
158
|
}
|
|
140
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
|
+
|
|
141
177
|
const handleDateSelect = (date: Date | undefined) => {
|
|
142
178
|
if (date) {
|
|
143
179
|
setSelectedDate(date)
|
|
@@ -146,18 +182,19 @@ function CalendarView({
|
|
|
146
182
|
}
|
|
147
183
|
|
|
148
184
|
return (
|
|
149
|
-
<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)}>
|
|
150
186
|
{/* Header */}
|
|
151
187
|
<div className="flex items-center justify-between p-4 border-b">
|
|
152
188
|
<div className="flex items-center gap-4">
|
|
153
189
|
<div className="flex items-center bg-muted/50 rounded-lg p-1 gap-1">
|
|
154
|
-
<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">
|
|
155
191
|
Today
|
|
156
192
|
</Button>
|
|
157
193
|
<div className="h-4 w-px bg-border mx-1" />
|
|
158
194
|
<Button
|
|
159
195
|
variant="ghost"
|
|
160
196
|
size="icon"
|
|
197
|
+
aria-label="Previous period"
|
|
161
198
|
onClick={handlePrevious}
|
|
162
199
|
className="h-8 w-8"
|
|
163
200
|
>
|
|
@@ -166,6 +203,7 @@ function CalendarView({
|
|
|
166
203
|
<Button
|
|
167
204
|
variant="ghost"
|
|
168
205
|
size="icon"
|
|
206
|
+
aria-label="Next period"
|
|
169
207
|
onClick={handleNext}
|
|
170
208
|
className="h-8 w-8"
|
|
171
209
|
>
|
|
@@ -177,6 +215,7 @@ function CalendarView({
|
|
|
177
215
|
<PopoverTrigger asChild>
|
|
178
216
|
<Button
|
|
179
217
|
variant="ghost"
|
|
218
|
+
aria-label={`Current date: ${getDateLabel()}`}
|
|
180
219
|
className={cn(
|
|
181
220
|
"text-xl font-semibold h-auto px-3 py-1 hover:bg-muted/50 transition-colors",
|
|
182
221
|
"flex items-center gap-2"
|
|
@@ -221,19 +260,21 @@ function CalendarView({
|
|
|
221
260
|
</div>
|
|
222
261
|
|
|
223
262
|
{/* Calendar Grid */}
|
|
224
|
-
<div className="flex-1 overflow-auto">
|
|
263
|
+
<div className="flex-1 overflow-auto" onTouchStart={handleTouchStart} onTouchEnd={handleTouchEnd}>
|
|
225
264
|
{selectedView === "month" && (
|
|
226
265
|
<MonthView
|
|
227
266
|
date={selectedDate}
|
|
228
267
|
events={events}
|
|
229
268
|
onEventClick={onEventClick}
|
|
230
269
|
onDateClick={onDateClick}
|
|
270
|
+
onEventDrop={onEventDrop}
|
|
231
271
|
/>
|
|
232
272
|
)}
|
|
233
273
|
{selectedView === "week" && (
|
|
234
274
|
<WeekView
|
|
235
275
|
date={selectedDate}
|
|
236
276
|
events={events}
|
|
277
|
+
locale={locale}
|
|
237
278
|
onEventClick={onEventClick}
|
|
238
279
|
onDateClick={onDateClick}
|
|
239
280
|
/>
|
|
@@ -243,6 +284,7 @@ function CalendarView({
|
|
|
243
284
|
date={selectedDate}
|
|
244
285
|
events={events}
|
|
245
286
|
onEventClick={onEventClick}
|
|
287
|
+
onDateClick={onDateClick}
|
|
246
288
|
/>
|
|
247
289
|
)}
|
|
248
290
|
</div>
|
|
@@ -322,20 +364,79 @@ interface MonthViewProps {
|
|
|
322
364
|
events: CalendarEvent[]
|
|
323
365
|
onEventClick?: (event: CalendarEvent) => void
|
|
324
366
|
onDateClick?: (date: Date) => void
|
|
367
|
+
onEventDrop?: (event: CalendarEvent, newStart: Date, newEnd?: Date) => void
|
|
325
368
|
}
|
|
326
369
|
|
|
327
|
-
function MonthView({ date, events, onEventClick, onDateClick }: MonthViewProps) {
|
|
370
|
+
function MonthView({ date, events, onEventClick, onDateClick, onEventDrop }: MonthViewProps) {
|
|
328
371
|
const days = getMonthDays(date)
|
|
329
372
|
const today = new Date()
|
|
330
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
|
+
}
|
|
331
431
|
|
|
332
432
|
return (
|
|
333
433
|
<div className="flex flex-col h-full">
|
|
334
434
|
{/* Week day headers */}
|
|
335
|
-
<div className="grid grid-cols-7 border-b">
|
|
435
|
+
<div role="row" className="grid grid-cols-7 border-b">
|
|
336
436
|
{weekDays.map((day) => (
|
|
337
437
|
<div
|
|
338
438
|
key={day}
|
|
439
|
+
role="columnheader"
|
|
339
440
|
className="p-2 text-center text-sm font-medium text-muted-foreground border-r last:border-r-0"
|
|
340
441
|
>
|
|
341
442
|
{day}
|
|
@@ -344,7 +445,7 @@ function MonthView({ date, events, onEventClick, onDateClick }: MonthViewProps)
|
|
|
344
445
|
</div>
|
|
345
446
|
|
|
346
447
|
{/* Calendar days */}
|
|
347
|
-
<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">
|
|
348
449
|
{days.map((day, index) => {
|
|
349
450
|
const dayEvents = getEventsForDate(day, events)
|
|
350
451
|
const isCurrentMonth = day.getMonth() === date.getMonth()
|
|
@@ -353,11 +454,17 @@ function MonthView({ date, events, onEventClick, onDateClick }: MonthViewProps)
|
|
|
353
454
|
return (
|
|
354
455
|
<div
|
|
355
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" : ""}` : ""}`}
|
|
356
459
|
className={cn(
|
|
357
460
|
"border-b border-r last:border-r-0 p-2 min-h-[100px] cursor-pointer hover:bg-accent/50",
|
|
358
|
-
!isCurrentMonth && "bg-muted/30 text-muted-foreground"
|
|
461
|
+
!isCurrentMonth && "bg-muted/30 text-muted-foreground",
|
|
462
|
+
dropTargetIndex === index && "ring-2 ring-primary"
|
|
359
463
|
)}
|
|
360
464
|
onClick={() => onDateClick?.(day)}
|
|
465
|
+
onDragOver={(e) => handleDragOver(e, index)}
|
|
466
|
+
onDragLeave={handleDragLeave}
|
|
467
|
+
onDrop={(e) => handleDrop(e, day)}
|
|
361
468
|
>
|
|
362
469
|
<div
|
|
363
470
|
className={cn(
|
|
@@ -365,6 +472,7 @@ function MonthView({ date, events, onEventClick, onDateClick }: MonthViewProps)
|
|
|
365
472
|
isToday &&
|
|
366
473
|
"inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground h-6 w-6"
|
|
367
474
|
)}
|
|
475
|
+
{...(isToday ? { "aria-current": "date" as const } : {})}
|
|
368
476
|
>
|
|
369
477
|
{day.getDate()}
|
|
370
478
|
</div>
|
|
@@ -372,9 +480,15 @@ function MonthView({ date, events, onEventClick, onDateClick }: MonthViewProps)
|
|
|
372
480
|
{dayEvents.slice(0, 3).map((event) => (
|
|
373
481
|
<div
|
|
374
482
|
key={event.id}
|
|
483
|
+
role="button"
|
|
484
|
+
aria-label={event.title}
|
|
485
|
+
draggable={!!onEventDrop}
|
|
486
|
+
onDragStart={(e) => handleDragStart(e, event)}
|
|
487
|
+
onDragEnd={handleDragEnd}
|
|
375
488
|
className={cn(
|
|
376
489
|
"text-xs px-2 py-1 rounded truncate cursor-pointer hover:opacity-80",
|
|
377
|
-
event.color || DEFAULT_EVENT_COLOR
|
|
490
|
+
event.color || DEFAULT_EVENT_COLOR,
|
|
491
|
+
draggedEventId === event.id && "opacity-50"
|
|
378
492
|
)}
|
|
379
493
|
style={
|
|
380
494
|
event.color && event.color.startsWith("#")
|
|
@@ -406,11 +520,28 @@ function MonthView({ date, events, onEventClick, onDateClick }: MonthViewProps)
|
|
|
406
520
|
interface WeekViewProps {
|
|
407
521
|
date: Date
|
|
408
522
|
events: CalendarEvent[]
|
|
523
|
+
locale?: string
|
|
409
524
|
onEventClick?: (event: CalendarEvent) => void
|
|
410
525
|
onDateClick?: (date: Date) => void
|
|
411
526
|
}
|
|
412
527
|
|
|
413
|
-
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
|
+
|
|
414
545
|
const weekStart = getWeekStart(date)
|
|
415
546
|
const weekDays = Array.from({ length: 7 }, (_, i) => {
|
|
416
547
|
const day = new Date(weekStart)
|
|
@@ -448,21 +579,27 @@ function WeekView({ date, events, onEventClick, onDateClick }: WeekViewProps) {
|
|
|
448
579
|
</div>
|
|
449
580
|
|
|
450
581
|
{/* Week events */}
|
|
451
|
-
<div className="grid grid-cols-7 flex-1">
|
|
582
|
+
<div role="grid" className="grid grid-cols-7 flex-1">
|
|
452
583
|
{weekDays.map((day) => {
|
|
453
584
|
const dayEvents = getEventsForDate(day, events)
|
|
454
585
|
return (
|
|
455
586
|
<div
|
|
456
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" : ""}` : ""}`}
|
|
457
590
|
className="border-r last:border-r-0 p-2 min-h-[400px] cursor-pointer hover:bg-accent/50"
|
|
458
591
|
onClick={() => onDateClick?.(day)}
|
|
592
|
+
onTouchStart={() => handleSlotTouchStart(day)}
|
|
593
|
+
onTouchEnd={handleSlotTouchEnd}
|
|
459
594
|
>
|
|
460
595
|
<div className="space-y-2">
|
|
461
596
|
{dayEvents.map((event) => (
|
|
462
597
|
<div
|
|
463
598
|
key={event.id}
|
|
599
|
+
role="button"
|
|
600
|
+
aria-label={event.title}
|
|
464
601
|
className={cn(
|
|
465
|
-
"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",
|
|
466
603
|
event.color || DEFAULT_EVENT_COLOR
|
|
467
604
|
)}
|
|
468
605
|
style={
|
|
@@ -475,7 +612,7 @@ function WeekView({ date, events, onEventClick, onDateClick }: WeekViewProps) {
|
|
|
475
612
|
onEventClick?.(event)
|
|
476
613
|
}}
|
|
477
614
|
>
|
|
478
|
-
<div className="font-medium">{event.title}</div>
|
|
615
|
+
<div className="font-medium truncate">{event.title}</div>
|
|
479
616
|
{!event.allDay && (
|
|
480
617
|
<div className="text-xs opacity-90 mt-1">
|
|
481
618
|
{event.start.toLocaleTimeString("default", {
|
|
@@ -499,15 +636,33 @@ interface DayViewProps {
|
|
|
499
636
|
date: Date
|
|
500
637
|
events: CalendarEvent[]
|
|
501
638
|
onEventClick?: (event: CalendarEvent) => void
|
|
639
|
+
onDateClick?: (date: Date) => void
|
|
502
640
|
}
|
|
503
641
|
|
|
504
|
-
function DayView({ date, events, onEventClick }: DayViewProps) {
|
|
642
|
+
function DayView({ date, events, onEventClick, onDateClick }: DayViewProps) {
|
|
505
643
|
const dayEvents = getEventsForDate(date, events)
|
|
506
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
|
+
}
|
|
507
662
|
|
|
508
663
|
return (
|
|
509
664
|
<div className="flex flex-col h-full">
|
|
510
|
-
<div className="flex-1 overflow-auto">
|
|
665
|
+
<div role="list" className="flex-1 overflow-auto">
|
|
511
666
|
{hours.map((hour) => {
|
|
512
667
|
const hourEvents = dayEvents.filter((event) => {
|
|
513
668
|
if (event.allDay) return hour === 0
|
|
@@ -516,7 +671,7 @@ function DayView({ date, events, onEventClick }: DayViewProps) {
|
|
|
516
671
|
})
|
|
517
672
|
|
|
518
673
|
return (
|
|
519
|
-
<div key={hour} className="flex border-b min-h-[60px]">
|
|
674
|
+
<div key={hour} role="listitem" className="flex border-b min-h-[60px]">
|
|
520
675
|
<div className="w-20 p-2 text-sm text-muted-foreground border-r">
|
|
521
676
|
{hour === 0
|
|
522
677
|
? "12 AM"
|
|
@@ -526,12 +681,17 @@ function DayView({ date, events, onEventClick }: DayViewProps) {
|
|
|
526
681
|
? "12 PM"
|
|
527
682
|
: `${hour - 12} PM`}
|
|
528
683
|
</div>
|
|
529
|
-
<div
|
|
684
|
+
<div
|
|
685
|
+
className="flex-1 p-2 space-y-2"
|
|
686
|
+
onTouchStart={() => handleSlotTouchStart(hour)}
|
|
687
|
+
onTouchEnd={handleSlotTouchEnd}
|
|
688
|
+
>
|
|
530
689
|
{hourEvents.map((event) => (
|
|
531
690
|
<div
|
|
532
691
|
key={event.id}
|
|
692
|
+
aria-label={event.title}
|
|
533
693
|
className={cn(
|
|
534
|
-
"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",
|
|
535
695
|
event.color || DEFAULT_EVENT_COLOR
|
|
536
696
|
)}
|
|
537
697
|
style={
|
|
@@ -541,7 +701,7 @@ function DayView({ date, events, onEventClick }: DayViewProps) {
|
|
|
541
701
|
}
|
|
542
702
|
onClick={() => onEventClick?.(event)}
|
|
543
703
|
>
|
|
544
|
-
<div className="font-medium">{event.title}</div>
|
|
704
|
+
<div className="font-medium truncate">{event.title}</div>
|
|
545
705
|
{!event.allDay && (
|
|
546
706
|
<div className="text-xs opacity-90 mt-1">
|
|
547
707
|
{event.start.toLocaleTimeString("default", {
|
|
@@ -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
|
+
};
|
package/src/ObjectCalendar.tsx
CHANGED
|
@@ -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
|
-
|
|
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) =>
|
|
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
|
};
|