@object-ui/plugin-calendar 3.3.0 → 3.3.1
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/CHANGELOG.md +13 -0
- package/README.md +21 -1
- package/dist/index.js +1 -1
- package/dist/index.umd.cjs +1 -1
- package/package.json +36 -13
- package/.turbo/turbo-build.log +0 -22
- package/src/CalendarView.test.tsx +0 -118
- package/src/CalendarView.tsx +0 -821
- package/src/ObjectCalendar.msw.test.tsx +0 -104
- package/src/ObjectCalendar.stories.tsx +0 -82
- package/src/ObjectCalendar.tsx +0 -433
- package/src/__tests__/accessibility.test.tsx +0 -290
- package/src/__tests__/calendar-bugfixes.test.tsx +0 -230
- package/src/__tests__/calendar-optimizations.test.tsx +0 -178
- package/src/__tests__/performance-benchmark.test.tsx +0 -227
- package/src/__tests__/view-states.test.tsx +0 -377
- package/src/calendar-view-renderer.tsx +0 -181
- package/src/index.tsx +0 -50
- package/src/registration.test.tsx +0 -41
- package/test/setup.ts +0 -32
- package/tsconfig.json +0 -18
- package/vite.config.ts +0 -57
- package/vitest.config.ts +0 -13
- package/vitest.setup.ts +0 -1
package/src/CalendarView.tsx
DELETED
|
@@ -1,821 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ObjectUI
|
|
3
|
-
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
-
*
|
|
5
|
-
* This source code is licensed under the MIT license found in the
|
|
6
|
-
* LICENSE file in the root directory of this source tree.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
"use client"
|
|
10
|
-
|
|
11
|
-
import * as React from "react"
|
|
12
|
-
import { ChevronLeftIcon, ChevronRightIcon, CalendarIcon, PlusIcon } from "lucide-react"
|
|
13
|
-
import {
|
|
14
|
-
cn,
|
|
15
|
-
Button,
|
|
16
|
-
Select,
|
|
17
|
-
SelectContent,
|
|
18
|
-
SelectItem,
|
|
19
|
-
SelectTrigger,
|
|
20
|
-
SelectValue,
|
|
21
|
-
Calendar,
|
|
22
|
-
Popover,
|
|
23
|
-
PopoverContent,
|
|
24
|
-
PopoverTrigger
|
|
25
|
-
} from "@object-ui/components"
|
|
26
|
-
import { useObjectTranslation } from "@object-ui/i18n"
|
|
27
|
-
|
|
28
|
-
const DEFAULT_EVENT_COLOR = "bg-blue-500 text-white"
|
|
29
|
-
const STABLE_DEFAULT_DATE = new Date()
|
|
30
|
-
|
|
31
|
-
// Default English translations for fallback when I18nProvider is not available
|
|
32
|
-
const DEFAULT_TRANSLATIONS: Record<string, string> = {
|
|
33
|
-
'calendar.today': 'Today',
|
|
34
|
-
'calendar.month': 'Month',
|
|
35
|
-
'calendar.week': 'Week',
|
|
36
|
-
'calendar.day': 'Day',
|
|
37
|
-
'calendar.newEvent': 'New event',
|
|
38
|
-
'calendar.moreEvents': '+{{count}} more',
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Safe wrapper for useObjectTranslation that falls back to English defaults
|
|
43
|
-
* when I18nProvider is not available (e.g., standalone usage outside console).
|
|
44
|
-
*/
|
|
45
|
-
function useCalendarTranslation() {
|
|
46
|
-
try {
|
|
47
|
-
const result = useObjectTranslation()
|
|
48
|
-
// Check if i18n is properly initialized by testing a known key
|
|
49
|
-
const testValue = result.t('calendar.today')
|
|
50
|
-
if (testValue === 'calendar.today') {
|
|
51
|
-
// i18n returned the key itself — not initialized
|
|
52
|
-
return {
|
|
53
|
-
t: (key: string, options?: Record<string, unknown>) => {
|
|
54
|
-
let value = DEFAULT_TRANSLATIONS[key] || key
|
|
55
|
-
if (options) {
|
|
56
|
-
for (const [k, v] of Object.entries(options)) {
|
|
57
|
-
value = value.replace(`{{${k}}}`, String(v))
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
return value
|
|
61
|
-
},
|
|
62
|
-
language: 'en',
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
return { t: result.t, language: result.language }
|
|
66
|
-
} catch {
|
|
67
|
-
return {
|
|
68
|
-
t: (key: string, options?: Record<string, unknown>) => {
|
|
69
|
-
let value = DEFAULT_TRANSLATIONS[key] || key
|
|
70
|
-
if (options) {
|
|
71
|
-
for (const [k, v] of Object.entries(options)) {
|
|
72
|
-
value = value.replace(`{{${k}}}`, String(v))
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
return value
|
|
76
|
-
},
|
|
77
|
-
language: 'en',
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
export interface CalendarEvent {
|
|
83
|
-
id: string | number
|
|
84
|
-
title: string
|
|
85
|
-
start: Date
|
|
86
|
-
end?: Date
|
|
87
|
-
allDay?: boolean
|
|
88
|
-
color?: string
|
|
89
|
-
data?: any
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
export interface CalendarViewProps {
|
|
93
|
-
events?: CalendarEvent[]
|
|
94
|
-
view?: "month" | "week" | "day"
|
|
95
|
-
currentDate?: Date
|
|
96
|
-
locale?: string
|
|
97
|
-
onEventClick?: (event: CalendarEvent) => void
|
|
98
|
-
onDateClick?: (date: Date) => void
|
|
99
|
-
onViewChange?: (view: "month" | "week" | "day") => void
|
|
100
|
-
onNavigate?: (date: Date) => void
|
|
101
|
-
onAddClick?: () => void
|
|
102
|
-
onEventDrop?: (event: CalendarEvent, newStart: Date, newEnd?: Date) => void
|
|
103
|
-
className?: string
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
function CalendarView({
|
|
107
|
-
events = [],
|
|
108
|
-
view = "month",
|
|
109
|
-
currentDate = STABLE_DEFAULT_DATE,
|
|
110
|
-
locale = "default",
|
|
111
|
-
onEventClick,
|
|
112
|
-
onDateClick,
|
|
113
|
-
onViewChange,
|
|
114
|
-
onNavigate,
|
|
115
|
-
onAddClick,
|
|
116
|
-
onEventDrop,
|
|
117
|
-
className,
|
|
118
|
-
}: CalendarViewProps) {
|
|
119
|
-
const [selectedView, setSelectedView] = React.useState(view)
|
|
120
|
-
const [selectedDate, setSelectedDate] = React.useState(currentDate)
|
|
121
|
-
const { t, language } = useCalendarTranslation()
|
|
122
|
-
const effectiveLocale = locale !== "default" ? locale : language
|
|
123
|
-
|
|
124
|
-
// Sync state if props change
|
|
125
|
-
React.useEffect(() => {
|
|
126
|
-
setSelectedDate(currentDate)
|
|
127
|
-
}, [currentDate])
|
|
128
|
-
|
|
129
|
-
React.useEffect(() => {
|
|
130
|
-
setSelectedView(view)
|
|
131
|
-
}, [view])
|
|
132
|
-
|
|
133
|
-
// Auto-switch to day view on mobile
|
|
134
|
-
const onViewChangeRef = React.useRef(onViewChange)
|
|
135
|
-
onViewChangeRef.current = onViewChange
|
|
136
|
-
|
|
137
|
-
React.useEffect(() => {
|
|
138
|
-
const mq = window.matchMedia("(max-width: 639px)")
|
|
139
|
-
const handleChange = (e: MediaQueryListEvent | MediaQueryList) => {
|
|
140
|
-
if (e.matches) {
|
|
141
|
-
setSelectedView("day")
|
|
142
|
-
onViewChangeRef.current?.("day")
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
handleChange(mq)
|
|
146
|
-
mq.addEventListener("change", handleChange)
|
|
147
|
-
return () => mq.removeEventListener("change", handleChange)
|
|
148
|
-
}, [])
|
|
149
|
-
|
|
150
|
-
const handlePrevious = () => {
|
|
151
|
-
const newDate = new Date(selectedDate)
|
|
152
|
-
if (selectedView === "month") {
|
|
153
|
-
newDate.setMonth(newDate.getMonth() - 1)
|
|
154
|
-
} else if (selectedView === "week") {
|
|
155
|
-
newDate.setDate(newDate.getDate() - 7)
|
|
156
|
-
} else {
|
|
157
|
-
newDate.setDate(newDate.getDate() - 1)
|
|
158
|
-
}
|
|
159
|
-
setSelectedDate(newDate)
|
|
160
|
-
onNavigate?.(newDate)
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
const handleNext = () => {
|
|
164
|
-
const newDate = new Date(selectedDate)
|
|
165
|
-
if (selectedView === "month") {
|
|
166
|
-
newDate.setMonth(newDate.getMonth() + 1)
|
|
167
|
-
} else if (selectedView === "week") {
|
|
168
|
-
newDate.setDate(newDate.getDate() + 7)
|
|
169
|
-
} else {
|
|
170
|
-
newDate.setDate(newDate.getDate() + 1)
|
|
171
|
-
}
|
|
172
|
-
setSelectedDate(newDate)
|
|
173
|
-
onNavigate?.(newDate)
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
const handleToday = () => {
|
|
177
|
-
const today = new Date()
|
|
178
|
-
setSelectedDate(today)
|
|
179
|
-
onNavigate?.(today)
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
const handleViewChange = (newView: "month" | "week" | "day") => {
|
|
183
|
-
setSelectedView(newView)
|
|
184
|
-
onViewChange?.(newView)
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
const getDateLabel = () => {
|
|
188
|
-
if (selectedView === "month") {
|
|
189
|
-
return selectedDate.toLocaleDateString(effectiveLocale, {
|
|
190
|
-
month: "long",
|
|
191
|
-
year: "numeric",
|
|
192
|
-
})
|
|
193
|
-
} else if (selectedView === "week") {
|
|
194
|
-
const weekStart = getWeekStart(selectedDate)
|
|
195
|
-
const weekEnd = new Date(weekStart)
|
|
196
|
-
weekEnd.setDate(weekEnd.getDate() + 6)
|
|
197
|
-
return `${weekStart.toLocaleDateString(effectiveLocale, {
|
|
198
|
-
month: "short",
|
|
199
|
-
day: "numeric",
|
|
200
|
-
})} - ${weekEnd.toLocaleDateString(effectiveLocale, {
|
|
201
|
-
month: "short",
|
|
202
|
-
day: "numeric",
|
|
203
|
-
year: "numeric",
|
|
204
|
-
})}`
|
|
205
|
-
} else {
|
|
206
|
-
return selectedDate.toLocaleDateString(effectiveLocale, {
|
|
207
|
-
weekday: "long",
|
|
208
|
-
month: "long",
|
|
209
|
-
day: "numeric",
|
|
210
|
-
year: "numeric",
|
|
211
|
-
})
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
// Swipe navigation for mobile
|
|
216
|
-
const touchStart = React.useRef<number>(0)
|
|
217
|
-
const handleTouchStart = (e: React.TouchEvent) => {
|
|
218
|
-
touchStart.current = e.touches[0].clientX
|
|
219
|
-
}
|
|
220
|
-
const handleTouchEnd = (e: React.TouchEvent) => {
|
|
221
|
-
const diff = touchStart.current - e.changedTouches[0].clientX
|
|
222
|
-
if (Math.abs(diff) > 50) {
|
|
223
|
-
const newDate = new Date(selectedDate)
|
|
224
|
-
if (selectedView === "day") newDate.setDate(newDate.getDate() + (diff > 0 ? 1 : -1))
|
|
225
|
-
else if (selectedView === "week") newDate.setDate(newDate.getDate() + (diff > 0 ? 7 : -7))
|
|
226
|
-
else newDate.setMonth(newDate.getMonth() + (diff > 0 ? 1 : -1))
|
|
227
|
-
setSelectedDate(newDate)
|
|
228
|
-
onNavigate?.(newDate)
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
const handleDateSelect = (date: Date | undefined) => {
|
|
233
|
-
if (date) {
|
|
234
|
-
setSelectedDate(date)
|
|
235
|
-
onNavigate?.(date)
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
return (
|
|
240
|
-
<div role="region" aria-label="Calendar" className={cn("flex flex-col h-full bg-background min-w-0 overflow-hidden", className)}>
|
|
241
|
-
{/* Header */}
|
|
242
|
-
<div className="flex flex-wrap items-center justify-between gap-2 p-2 sm:p-4 border-b min-w-0">
|
|
243
|
-
<div className="flex items-center gap-4">
|
|
244
|
-
<div className="flex items-center bg-muted/50 rounded-lg p-1 gap-1">
|
|
245
|
-
<Button variant="ghost" size="sm" onClick={handleToday} className="h-8" aria-label="Go to today">
|
|
246
|
-
{t('calendar.today')}
|
|
247
|
-
</Button>
|
|
248
|
-
<div className="h-4 w-px bg-border mx-1" />
|
|
249
|
-
<Button
|
|
250
|
-
variant="ghost"
|
|
251
|
-
size="icon"
|
|
252
|
-
aria-label="Previous period"
|
|
253
|
-
onClick={handlePrevious}
|
|
254
|
-
className="h-8 w-8"
|
|
255
|
-
>
|
|
256
|
-
<ChevronLeftIcon className="h-4 w-4" />
|
|
257
|
-
</Button>
|
|
258
|
-
<Button
|
|
259
|
-
variant="ghost"
|
|
260
|
-
size="icon"
|
|
261
|
-
aria-label="Next period"
|
|
262
|
-
onClick={handleNext}
|
|
263
|
-
className="h-8 w-8"
|
|
264
|
-
>
|
|
265
|
-
<ChevronRightIcon className="h-4 w-4" />
|
|
266
|
-
</Button>
|
|
267
|
-
</div>
|
|
268
|
-
|
|
269
|
-
<Popover>
|
|
270
|
-
<PopoverTrigger asChild>
|
|
271
|
-
<Button
|
|
272
|
-
variant="ghost"
|
|
273
|
-
aria-label={`Current date: ${getDateLabel()}`}
|
|
274
|
-
className={cn(
|
|
275
|
-
"text-base sm:text-xl font-semibold h-auto px-2 sm:px-3 py-1 hover:bg-muted/50 transition-colors",
|
|
276
|
-
"flex items-center gap-2"
|
|
277
|
-
)}
|
|
278
|
-
>
|
|
279
|
-
<CalendarIcon className="h-5 w-5 text-muted-foreground" />
|
|
280
|
-
<span>{getDateLabel()}</span>
|
|
281
|
-
</Button>
|
|
282
|
-
</PopoverTrigger>
|
|
283
|
-
<PopoverContent className="w-auto p-0" align="start">
|
|
284
|
-
<Calendar
|
|
285
|
-
mode="single"
|
|
286
|
-
selected={selectedDate}
|
|
287
|
-
onSelect={handleDateSelect}
|
|
288
|
-
initialFocus
|
|
289
|
-
fromYear={2000}
|
|
290
|
-
toYear={2050}
|
|
291
|
-
/>
|
|
292
|
-
</PopoverContent>
|
|
293
|
-
</Popover>
|
|
294
|
-
</div>
|
|
295
|
-
|
|
296
|
-
<div className="flex items-center gap-2">
|
|
297
|
-
<Select value={selectedView} onValueChange={handleViewChange}>
|
|
298
|
-
<SelectTrigger className="w-32 bg-background">
|
|
299
|
-
<SelectValue />
|
|
300
|
-
</SelectTrigger>
|
|
301
|
-
<SelectContent>
|
|
302
|
-
<SelectItem value="day">{t('calendar.day')}</SelectItem>
|
|
303
|
-
<SelectItem value="week">{t('calendar.week')}</SelectItem>
|
|
304
|
-
<SelectItem value="month">{t('calendar.month')}</SelectItem>
|
|
305
|
-
</SelectContent>
|
|
306
|
-
</Select>
|
|
307
|
-
|
|
308
|
-
{onAddClick && (
|
|
309
|
-
<Button onClick={onAddClick} size="sm" className="gap-1">
|
|
310
|
-
<PlusIcon className="h-4 w-4" />
|
|
311
|
-
{t('calendar.newEvent')}
|
|
312
|
-
</Button>
|
|
313
|
-
)}
|
|
314
|
-
</div>
|
|
315
|
-
</div>
|
|
316
|
-
|
|
317
|
-
{/* Calendar Grid */}
|
|
318
|
-
<div className="flex-1 overflow-auto" onTouchStart={handleTouchStart} onTouchEnd={handleTouchEnd}>
|
|
319
|
-
{selectedView === "month" && (
|
|
320
|
-
<MonthView
|
|
321
|
-
date={selectedDate}
|
|
322
|
-
events={events}
|
|
323
|
-
locale={effectiveLocale}
|
|
324
|
-
onEventClick={onEventClick}
|
|
325
|
-
onDateClick={onDateClick}
|
|
326
|
-
onEventDrop={onEventDrop}
|
|
327
|
-
/>
|
|
328
|
-
)}
|
|
329
|
-
{selectedView === "week" && (
|
|
330
|
-
<WeekView
|
|
331
|
-
date={selectedDate}
|
|
332
|
-
events={events}
|
|
333
|
-
locale={effectiveLocale}
|
|
334
|
-
onEventClick={onEventClick}
|
|
335
|
-
onDateClick={onDateClick}
|
|
336
|
-
/>
|
|
337
|
-
)}
|
|
338
|
-
{selectedView === "day" && (
|
|
339
|
-
<DayView
|
|
340
|
-
date={selectedDate}
|
|
341
|
-
events={events}
|
|
342
|
-
onEventClick={onEventClick}
|
|
343
|
-
onDateClick={onDateClick}
|
|
344
|
-
/>
|
|
345
|
-
)}
|
|
346
|
-
</div>
|
|
347
|
-
</div>
|
|
348
|
-
)
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
function getWeekStart(date: Date): Date {
|
|
352
|
-
const d = new Date(date)
|
|
353
|
-
const day = d.getDay()
|
|
354
|
-
const diff = d.getDate() - day
|
|
355
|
-
d.setDate(diff)
|
|
356
|
-
return d
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
function getMonthDays(date: Date): Date[] {
|
|
360
|
-
const year = date.getFullYear()
|
|
361
|
-
const month = date.getMonth()
|
|
362
|
-
const firstDay = new Date(year, month, 1)
|
|
363
|
-
const lastDay = new Date(year, month + 1, 0)
|
|
364
|
-
const startDay = firstDay.getDay()
|
|
365
|
-
const days: Date[] = []
|
|
366
|
-
|
|
367
|
-
// Add previous month days
|
|
368
|
-
for (let i = startDay - 1; i >= 0; i--) {
|
|
369
|
-
const prevDate = new Date(firstDay.getTime())
|
|
370
|
-
prevDate.setDate(prevDate.getDate() - (i + 1))
|
|
371
|
-
days.push(prevDate)
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
// Add current month days
|
|
375
|
-
for (let i = 1; i <= lastDay.getDate(); i++) {
|
|
376
|
-
days.push(new Date(year, month, i))
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
// Add next month days
|
|
380
|
-
const remainingDays = 42 - days.length
|
|
381
|
-
for (let i = 1; i <= remainingDays; i++) {
|
|
382
|
-
const nextDate = new Date(lastDay.getTime())
|
|
383
|
-
nextDate.setDate(nextDate.getDate() + i)
|
|
384
|
-
days.push(nextDate)
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
return days
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
function isSameDay(date1: Date, date2: Date): boolean {
|
|
391
|
-
return (
|
|
392
|
-
date1.getFullYear() === date2.getFullYear() &&
|
|
393
|
-
date1.getMonth() === date2.getMonth() &&
|
|
394
|
-
date1.getDate() === date2.getDate()
|
|
395
|
-
)
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
function getEventsForDate(date: Date, events: CalendarEvent[]): CalendarEvent[] {
|
|
399
|
-
return events.filter((event) => {
|
|
400
|
-
const eventStart = new Date(event.start)
|
|
401
|
-
const eventEnd = event.end ? new Date(event.end) : new Date(eventStart)
|
|
402
|
-
|
|
403
|
-
// Create new date objects for comparison to avoid mutation
|
|
404
|
-
const dateStart = new Date(date)
|
|
405
|
-
dateStart.setHours(0, 0, 0, 0)
|
|
406
|
-
const dateEnd = new Date(date)
|
|
407
|
-
dateEnd.setHours(23, 59, 59, 999)
|
|
408
|
-
|
|
409
|
-
const eventStartTime = new Date(eventStart)
|
|
410
|
-
eventStartTime.setHours(0, 0, 0, 0)
|
|
411
|
-
const eventEndTime = new Date(eventEnd)
|
|
412
|
-
eventEndTime.setHours(23, 59, 59, 999)
|
|
413
|
-
|
|
414
|
-
return dateStart <= eventEndTime && dateEnd >= eventStartTime
|
|
415
|
-
})
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
interface MonthViewProps {
|
|
419
|
-
date: Date
|
|
420
|
-
events: CalendarEvent[]
|
|
421
|
-
locale?: string
|
|
422
|
-
onEventClick?: (event: CalendarEvent) => void
|
|
423
|
-
onDateClick?: (date: Date) => void
|
|
424
|
-
onEventDrop?: (event: CalendarEvent, newStart: Date, newEnd?: Date) => void
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
function MonthView({ date, events, locale = "default", onEventClick, onDateClick, onEventDrop }: MonthViewProps) {
|
|
428
|
-
const days = React.useMemo(() => getMonthDays(date), [date.getFullYear(), date.getMonth()])
|
|
429
|
-
const today = React.useMemo(() => new Date(), [])
|
|
430
|
-
const { t } = useCalendarTranslation()
|
|
431
|
-
const weekDays = React.useMemo(() => {
|
|
432
|
-
const refSunday = new Date(2024, 0, 7)
|
|
433
|
-
return Array.from({ length: 7 }, (_, i) => {
|
|
434
|
-
const d = new Date(refSunday)
|
|
435
|
-
d.setDate(d.getDate() + i)
|
|
436
|
-
return d.toLocaleDateString(locale, { weekday: "short" })
|
|
437
|
-
})
|
|
438
|
-
}, [locale])
|
|
439
|
-
const [draggedEventId, setDraggedEventId] = React.useState<string | number | null>(null)
|
|
440
|
-
const [dropTargetIndex, setDropTargetIndex] = React.useState<number | null>(null)
|
|
441
|
-
|
|
442
|
-
// Pre-build event index by date key for O(1) lookup per cell instead of O(N)
|
|
443
|
-
const eventsByDate = React.useMemo(() => {
|
|
444
|
-
const map = new Map<string, CalendarEvent[]>()
|
|
445
|
-
for (const event of events) {
|
|
446
|
-
const eventStart = new Date(event.start)
|
|
447
|
-
const eventEnd = event.end ? new Date(event.end) : new Date(eventStart)
|
|
448
|
-
eventStart.setHours(0, 0, 0, 0)
|
|
449
|
-
eventEnd.setHours(0, 0, 0, 0)
|
|
450
|
-
const cursor = new Date(eventStart)
|
|
451
|
-
while (cursor <= eventEnd) {
|
|
452
|
-
const key = `${cursor.getFullYear()}-${cursor.getMonth()}-${cursor.getDate()}`
|
|
453
|
-
const arr = map.get(key)
|
|
454
|
-
if (arr) {
|
|
455
|
-
arr.push(event)
|
|
456
|
-
} else {
|
|
457
|
-
map.set(key, [event])
|
|
458
|
-
}
|
|
459
|
-
cursor.setDate(cursor.getDate() + 1)
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
return map
|
|
463
|
-
}, [events])
|
|
464
|
-
|
|
465
|
-
const handleDragStart = (e: React.DragEvent, event: CalendarEvent) => {
|
|
466
|
-
setDraggedEventId(event.id)
|
|
467
|
-
e.dataTransfer.effectAllowed = "move"
|
|
468
|
-
e.dataTransfer.setData("text/plain", String(event.id))
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
const handleDragEnd = () => {
|
|
472
|
-
setDraggedEventId(null)
|
|
473
|
-
setDropTargetIndex(null)
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
const handleDragOver = (e: React.DragEvent, index: number) => {
|
|
477
|
-
e.preventDefault()
|
|
478
|
-
e.dataTransfer.dropEffect = "move"
|
|
479
|
-
setDropTargetIndex(index)
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
const handleDragLeave = (e: React.DragEvent) => {
|
|
483
|
-
// Only clear when actually leaving the cell, not when moving over child elements
|
|
484
|
-
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
|
|
485
|
-
setDropTargetIndex(null)
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
const handleDrop = (e: React.DragEvent, targetDay: Date) => {
|
|
490
|
-
e.preventDefault()
|
|
491
|
-
setDropTargetIndex(null)
|
|
492
|
-
setDraggedEventId(null)
|
|
493
|
-
|
|
494
|
-
if (!onEventDrop) return
|
|
495
|
-
|
|
496
|
-
const eventId = e.dataTransfer.getData("text/plain")
|
|
497
|
-
const draggedEvent = events.find((ev) => String(ev.id) === eventId)
|
|
498
|
-
if (!draggedEvent) return
|
|
499
|
-
|
|
500
|
-
const oldStart = new Date(draggedEvent.start)
|
|
501
|
-
const oldStartDay = new Date(oldStart)
|
|
502
|
-
oldStartDay.setHours(0, 0, 0, 0)
|
|
503
|
-
|
|
504
|
-
const newTargetDay = new Date(targetDay)
|
|
505
|
-
newTargetDay.setHours(0, 0, 0, 0)
|
|
506
|
-
|
|
507
|
-
const deltaMs = newTargetDay.getTime() - oldStartDay.getTime()
|
|
508
|
-
if (deltaMs === 0) return
|
|
509
|
-
|
|
510
|
-
const newStart = new Date(oldStart.getTime() + deltaMs)
|
|
511
|
-
|
|
512
|
-
let newEnd: Date | undefined
|
|
513
|
-
if (draggedEvent.end) {
|
|
514
|
-
newEnd = new Date(new Date(draggedEvent.end).getTime() + deltaMs)
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
onEventDrop(draggedEvent, newStart, newEnd)
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
return (
|
|
521
|
-
<div className="flex flex-col h-full">
|
|
522
|
-
{/* Week day headers */}
|
|
523
|
-
<div role="row" className="grid grid-cols-7 border-b">
|
|
524
|
-
{weekDays.map((day) => (
|
|
525
|
-
<div
|
|
526
|
-
key={day}
|
|
527
|
-
role="columnheader"
|
|
528
|
-
className="p-2 text-center text-sm font-medium text-muted-foreground border-r last:border-r-0"
|
|
529
|
-
>
|
|
530
|
-
{day}
|
|
531
|
-
</div>
|
|
532
|
-
))}
|
|
533
|
-
</div>
|
|
534
|
-
|
|
535
|
-
{/* Calendar days */}
|
|
536
|
-
<div role="grid" aria-label="Calendar grid" className="grid grid-cols-7 flex-1 auto-rows-fr">
|
|
537
|
-
{days.map((day, index) => {
|
|
538
|
-
const key = `${day.getFullYear()}-${day.getMonth()}-${day.getDate()}`
|
|
539
|
-
const dayEvents = eventsByDate.get(key) || []
|
|
540
|
-
const isCurrentMonth = day.getMonth() === date.getMonth()
|
|
541
|
-
const isToday = isSameDay(day, today)
|
|
542
|
-
|
|
543
|
-
return (
|
|
544
|
-
<div
|
|
545
|
-
key={index}
|
|
546
|
-
role="gridcell"
|
|
547
|
-
aria-label={`${day.toLocaleDateString("default", { weekday: "long", month: "long", day: "numeric", year: "numeric" })}${dayEvents.length > 0 ? `, ${dayEvents.length} event${dayEvents.length > 1 ? "s" : ""}` : ""}`}
|
|
548
|
-
className={cn(
|
|
549
|
-
"border-b border-r last:border-r-0 p-2 min-h-[100px] cursor-pointer hover:bg-accent/50",
|
|
550
|
-
!isCurrentMonth && "bg-muted/50 text-muted-foreground opacity-50",
|
|
551
|
-
dropTargetIndex === index && "ring-2 ring-primary"
|
|
552
|
-
)}
|
|
553
|
-
onClick={() => onDateClick?.(day)}
|
|
554
|
-
onDragOver={(e) => handleDragOver(e, index)}
|
|
555
|
-
onDragLeave={handleDragLeave}
|
|
556
|
-
onDrop={(e) => handleDrop(e, day)}
|
|
557
|
-
>
|
|
558
|
-
<div
|
|
559
|
-
className={cn(
|
|
560
|
-
"text-sm font-medium mb-2",
|
|
561
|
-
isToday &&
|
|
562
|
-
"inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground h-6 w-6"
|
|
563
|
-
)}
|
|
564
|
-
{...(isToday ? { "aria-current": "date" as const } : {})}
|
|
565
|
-
>
|
|
566
|
-
{day.getDate()}
|
|
567
|
-
</div>
|
|
568
|
-
<div className="space-y-1">
|
|
569
|
-
{dayEvents.slice(0, 3).map((event) => (
|
|
570
|
-
<div
|
|
571
|
-
key={event.id}
|
|
572
|
-
role="button"
|
|
573
|
-
title={event.title}
|
|
574
|
-
aria-label={event.title}
|
|
575
|
-
draggable={!!onEventDrop}
|
|
576
|
-
onDragStart={(e) => handleDragStart(e, event)}
|
|
577
|
-
onDragEnd={handleDragEnd}
|
|
578
|
-
className={cn(
|
|
579
|
-
"text-xs px-2 py-1 rounded truncate cursor-pointer hover:opacity-80",
|
|
580
|
-
event.color?.startsWith("#") ? "text-white" : (event.color || DEFAULT_EVENT_COLOR),
|
|
581
|
-
draggedEventId === event.id && "opacity-50"
|
|
582
|
-
)}
|
|
583
|
-
style={
|
|
584
|
-
event.color && event.color.startsWith("#")
|
|
585
|
-
? { backgroundColor: event.color }
|
|
586
|
-
: undefined
|
|
587
|
-
}
|
|
588
|
-
onClick={(e) => {
|
|
589
|
-
e.stopPropagation()
|
|
590
|
-
onEventClick?.(event)
|
|
591
|
-
}}
|
|
592
|
-
>
|
|
593
|
-
{event.title}
|
|
594
|
-
</div>
|
|
595
|
-
))}
|
|
596
|
-
{dayEvents.length > 3 && (
|
|
597
|
-
<div className="text-xs text-muted-foreground px-2">
|
|
598
|
-
{t('calendar.moreEvents', { count: dayEvents.length - 3 })}
|
|
599
|
-
</div>
|
|
600
|
-
)}
|
|
601
|
-
</div>
|
|
602
|
-
</div>
|
|
603
|
-
)
|
|
604
|
-
})}
|
|
605
|
-
</div>
|
|
606
|
-
</div>
|
|
607
|
-
)
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
interface WeekViewProps {
|
|
611
|
-
date: Date
|
|
612
|
-
events: CalendarEvent[]
|
|
613
|
-
locale?: string
|
|
614
|
-
onEventClick?: (event: CalendarEvent) => void
|
|
615
|
-
onDateClick?: (date: Date) => void
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
function WeekView({ date, events, locale = "default", onEventClick, onDateClick }: WeekViewProps) {
|
|
619
|
-
const longPressTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
620
|
-
|
|
621
|
-
const handleSlotTouchStart = (day: Date) => {
|
|
622
|
-
if (!onDateClick) return
|
|
623
|
-
longPressTimer.current = setTimeout(() => {
|
|
624
|
-
onDateClick(day)
|
|
625
|
-
}, 500)
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
const handleSlotTouchEnd = () => {
|
|
629
|
-
if (longPressTimer.current) {
|
|
630
|
-
clearTimeout(longPressTimer.current)
|
|
631
|
-
longPressTimer.current = null
|
|
632
|
-
}
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
const weekStart = getWeekStart(date)
|
|
636
|
-
const weekDays = Array.from({ length: 7 }, (_, i) => {
|
|
637
|
-
const day = new Date(weekStart)
|
|
638
|
-
day.setDate(day.getDate() + i)
|
|
639
|
-
return day
|
|
640
|
-
})
|
|
641
|
-
const today = new Date()
|
|
642
|
-
|
|
643
|
-
return (
|
|
644
|
-
<div className="flex flex-col h-full">
|
|
645
|
-
{/* Week day headers */}
|
|
646
|
-
<div className="grid grid-cols-7 border-b">
|
|
647
|
-
{weekDays.map((day) => {
|
|
648
|
-
const isToday = isSameDay(day, today)
|
|
649
|
-
return (
|
|
650
|
-
<div
|
|
651
|
-
key={day.toISOString()}
|
|
652
|
-
className="p-3 text-center border-r last:border-r-0"
|
|
653
|
-
>
|
|
654
|
-
<div className="text-sm font-medium text-muted-foreground">
|
|
655
|
-
{day.toLocaleDateString(locale, { weekday: "short" })}
|
|
656
|
-
</div>
|
|
657
|
-
<div
|
|
658
|
-
className={cn(
|
|
659
|
-
"text-lg font-semibold mt-1",
|
|
660
|
-
isToday &&
|
|
661
|
-
"inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground h-8 w-8"
|
|
662
|
-
)}
|
|
663
|
-
>
|
|
664
|
-
{day.getDate()}
|
|
665
|
-
</div>
|
|
666
|
-
</div>
|
|
667
|
-
)
|
|
668
|
-
})}
|
|
669
|
-
</div>
|
|
670
|
-
|
|
671
|
-
{/* Week events */}
|
|
672
|
-
<div role="grid" className="grid grid-cols-7 flex-1">
|
|
673
|
-
{weekDays.map((day) => {
|
|
674
|
-
const dayEvents = getEventsForDate(day, events)
|
|
675
|
-
return (
|
|
676
|
-
<div
|
|
677
|
-
key={day.toISOString()}
|
|
678
|
-
role="gridcell"
|
|
679
|
-
aria-label={`${day.toLocaleDateString("default", { weekday: "long", month: "long", day: "numeric", year: "numeric" })}${dayEvents.length > 0 ? `, ${dayEvents.length} event${dayEvents.length > 1 ? "s" : ""}` : ""}`}
|
|
680
|
-
className="border-r last:border-r-0 p-2 min-h-[400px] cursor-pointer hover:bg-accent/50"
|
|
681
|
-
onClick={() => onDateClick?.(day)}
|
|
682
|
-
onTouchStart={() => handleSlotTouchStart(day)}
|
|
683
|
-
onTouchEnd={handleSlotTouchEnd}
|
|
684
|
-
>
|
|
685
|
-
<div className="space-y-2">
|
|
686
|
-
{dayEvents.map((event) => (
|
|
687
|
-
<div
|
|
688
|
-
key={event.id}
|
|
689
|
-
role="button"
|
|
690
|
-
title={event.title}
|
|
691
|
-
aria-label={event.title}
|
|
692
|
-
className={cn(
|
|
693
|
-
"text-xs sm:text-sm px-2 sm:px-3 py-1.5 sm:py-2 rounded cursor-pointer hover:opacity-80",
|
|
694
|
-
event.color?.startsWith("#") ? "text-white" : (event.color || DEFAULT_EVENT_COLOR)
|
|
695
|
-
)}
|
|
696
|
-
style={
|
|
697
|
-
event.color && event.color.startsWith("#")
|
|
698
|
-
? { backgroundColor: event.color }
|
|
699
|
-
: undefined
|
|
700
|
-
}
|
|
701
|
-
onClick={(e) => {
|
|
702
|
-
e.stopPropagation()
|
|
703
|
-
onEventClick?.(event)
|
|
704
|
-
}}
|
|
705
|
-
>
|
|
706
|
-
<div className="font-medium truncate">{event.title}</div>
|
|
707
|
-
{!event.allDay && (
|
|
708
|
-
<div className="text-xs opacity-90 mt-1">
|
|
709
|
-
{event.start.toLocaleTimeString("default", {
|
|
710
|
-
hour: "numeric",
|
|
711
|
-
minute: "2-digit",
|
|
712
|
-
})}
|
|
713
|
-
</div>
|
|
714
|
-
)}
|
|
715
|
-
</div>
|
|
716
|
-
))}
|
|
717
|
-
</div>
|
|
718
|
-
</div>
|
|
719
|
-
)
|
|
720
|
-
})}
|
|
721
|
-
</div>
|
|
722
|
-
</div>
|
|
723
|
-
)
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
interface DayViewProps {
|
|
727
|
-
date: Date
|
|
728
|
-
events: CalendarEvent[]
|
|
729
|
-
onEventClick?: (event: CalendarEvent) => void
|
|
730
|
-
onDateClick?: (date: Date) => void
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
function DayView({ date, events, onEventClick, onDateClick }: DayViewProps) {
|
|
734
|
-
const dayEvents = getEventsForDate(date, events)
|
|
735
|
-
const hours = Array.from({ length: 24 }, (_, i) => i)
|
|
736
|
-
const longPressTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
737
|
-
|
|
738
|
-
const handleSlotTouchStart = (hour: number) => {
|
|
739
|
-
if (!onDateClick) return
|
|
740
|
-
longPressTimer.current = setTimeout(() => {
|
|
741
|
-
const clickDate = new Date(date)
|
|
742
|
-
clickDate.setHours(hour, 0, 0, 0)
|
|
743
|
-
onDateClick(clickDate)
|
|
744
|
-
}, 500)
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
const handleSlotTouchEnd = () => {
|
|
748
|
-
if (longPressTimer.current) {
|
|
749
|
-
clearTimeout(longPressTimer.current)
|
|
750
|
-
longPressTimer.current = null
|
|
751
|
-
}
|
|
752
|
-
}
|
|
753
|
-
|
|
754
|
-
return (
|
|
755
|
-
<div className="flex flex-col h-full">
|
|
756
|
-
<div role="list" className="flex-1 overflow-auto">
|
|
757
|
-
{hours.map((hour) => {
|
|
758
|
-
const hourEvents = dayEvents.filter((event) => {
|
|
759
|
-
if (event.allDay) return hour === 0
|
|
760
|
-
const eventHour = event.start.getHours()
|
|
761
|
-
return eventHour === hour
|
|
762
|
-
})
|
|
763
|
-
|
|
764
|
-
return (
|
|
765
|
-
<div key={hour} role="listitem" className="flex border-b min-h-[60px]">
|
|
766
|
-
<div className="w-20 p-2 text-sm text-muted-foreground border-r">
|
|
767
|
-
{hour === 0
|
|
768
|
-
? "12 AM"
|
|
769
|
-
: hour < 12
|
|
770
|
-
? `${hour} AM`
|
|
771
|
-
: hour === 12
|
|
772
|
-
? "12 PM"
|
|
773
|
-
: `${hour - 12} PM`}
|
|
774
|
-
</div>
|
|
775
|
-
<div
|
|
776
|
-
className="flex-1 p-2 space-y-2"
|
|
777
|
-
onTouchStart={() => handleSlotTouchStart(hour)}
|
|
778
|
-
onTouchEnd={handleSlotTouchEnd}
|
|
779
|
-
>
|
|
780
|
-
{hourEvents.map((event) => (
|
|
781
|
-
<div
|
|
782
|
-
key={event.id}
|
|
783
|
-
title={event.title}
|
|
784
|
-
aria-label={event.title}
|
|
785
|
-
className={cn(
|
|
786
|
-
"px-2 sm:px-3 py-1.5 sm:py-2 rounded cursor-pointer hover:opacity-80",
|
|
787
|
-
event.color?.startsWith("#") ? "text-white" : (event.color || DEFAULT_EVENT_COLOR)
|
|
788
|
-
)}
|
|
789
|
-
style={
|
|
790
|
-
event.color && event.color.startsWith("#")
|
|
791
|
-
? { backgroundColor: event.color }
|
|
792
|
-
: undefined
|
|
793
|
-
}
|
|
794
|
-
onClick={() => onEventClick?.(event)}
|
|
795
|
-
>
|
|
796
|
-
<div className="font-medium truncate">{event.title}</div>
|
|
797
|
-
{!event.allDay && (
|
|
798
|
-
<div className="text-xs opacity-90 mt-1">
|
|
799
|
-
{event.start.toLocaleTimeString("default", {
|
|
800
|
-
hour: "numeric",
|
|
801
|
-
minute: "2-digit",
|
|
802
|
-
})}
|
|
803
|
-
{event.end &&
|
|
804
|
-
` - ${event.end.toLocaleTimeString("default", {
|
|
805
|
-
hour: "numeric",
|
|
806
|
-
minute: "2-digit",
|
|
807
|
-
})}`}
|
|
808
|
-
</div>
|
|
809
|
-
)}
|
|
810
|
-
</div>
|
|
811
|
-
))}
|
|
812
|
-
</div>
|
|
813
|
-
</div>
|
|
814
|
-
)
|
|
815
|
-
})}
|
|
816
|
-
</div>
|
|
817
|
-
</div>
|
|
818
|
-
)
|
|
819
|
-
}
|
|
820
|
-
|
|
821
|
-
export { CalendarView }
|