@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.
- package/.turbo/turbo-build.log +11 -6
- package/CHANGELOG.md +37 -0
- package/dist/index.js +640 -485
- package/dist/index.umd.cjs +2 -2
- package/dist/src/CalendarView.d.ts +3 -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.test.tsx +6 -6
- package/src/CalendarView.tsx +185 -23
- package/src/ObjectCalendar.msw.test.tsx +8 -4
- 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/index.tsx +11 -1
package/src/CalendarView.tsx
CHANGED
|
@@ -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(
|
|
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(
|
|
142
|
+
return `${weekStart.toLocaleDateString(locale, {
|
|
122
143
|
month: "short",
|
|
123
144
|
day: "numeric",
|
|
124
|
-
})} - ${weekEnd.toLocaleDateString(
|
|
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(
|
|
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(
|
|
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
|
|
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:
|
|
20
|
-
end: new Date(
|
|
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(
|
|
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/
|
|
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
|
+
};
|
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
|
};
|