@srcroot/ui 0.0.54 → 0.0.56
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/README.md +151 -151
- package/dist/index.d.ts +0 -0
- package/dist/index.js +55 -1
- package/package.json +7 -2
- package/src/registry/analytics/google-analytics.tsx +36 -39
- package/src/registry/analytics/google-tag-manager.tsx +62 -65
- package/src/registry/analytics/meta-pixel.tsx +44 -47
- package/src/registry/analytics/microsoft-clarity.tsx +31 -34
- package/src/registry/analytics/tiktok-pixel.tsx +34 -37
- package/src/registry/lib/utils.ts +0 -0
- package/src/registry/themes/v3/blue.css +157 -157
- package/src/registry/themes/v3/glass.css +153 -153
- package/src/registry/themes/v3/gray.css +157 -157
- package/src/registry/themes/v3/green.css +157 -157
- package/src/registry/themes/v3/neutral.css +157 -157
- package/src/registry/themes/v3/orange.css +157 -157
- package/src/registry/themes/v3/rose.css +157 -157
- package/src/registry/themes/v3/slate.css +157 -157
- package/src/registry/themes/v3/stone.css +157 -157
- package/src/registry/themes/v3/violet.css +186 -186
- package/src/registry/themes/v3/zinc.css +157 -157
- package/src/registry/themes/v4/blue.css +184 -184
- package/src/registry/themes/v4/glass.css +180 -180
- package/src/registry/themes/v4/gray.css +184 -184
- package/src/registry/themes/v4/green.css +184 -184
- package/src/registry/themes/v4/neutral.css +184 -184
- package/src/registry/themes/v4/orange.css +184 -184
- package/src/registry/themes/v4/rose.css +184 -184
- package/src/registry/themes/v4/slate.css +184 -184
- package/src/registry/themes/v4/stone.css +184 -184
- package/src/registry/themes/v4/violet.css +184 -184
- package/src/registry/themes/v4/zinc.css +184 -184
- package/src/registry/ui/accordion.tsx +164 -165
- package/src/registry/ui/alert-dialog.tsx +213 -214
- package/src/registry/ui/alert.tsx +73 -76
- package/src/registry/ui/aspect-ratio.tsx +44 -47
- package/src/registry/ui/avatar.tsx +96 -97
- package/src/registry/ui/badge.tsx +52 -55
- package/src/registry/ui/breadcrumb.tsx +147 -150
- package/src/registry/ui/button-group.tsx +64 -67
- package/src/registry/ui/button.tsx +71 -72
- package/src/registry/ui/calendar.tsx +514 -515
- package/src/registry/ui/card.tsx +88 -91
- package/src/registry/ui/carousel.tsx +214 -214
- package/src/registry/ui/chart.tsx +373 -373
- package/src/registry/ui/chatbot.tsx +86 -13
- package/src/registry/ui/checkbox.tsx +93 -94
- package/src/registry/ui/collapsible.tsx +107 -108
- package/src/registry/ui/combobox.tsx +171 -171
- package/src/registry/ui/command.tsx +300 -300
- package/src/registry/ui/container.tsx +44 -47
- package/src/registry/ui/context-menu.tsx +221 -221
- package/src/registry/ui/date-picker.tsx +228 -228
- package/src/registry/ui/dialog.tsx +269 -270
- package/src/registry/ui/drawer.tsx +10 -4
- package/src/registry/ui/dropdown-menu.tsx +529 -530
- package/src/registry/ui/empty-state.tsx +0 -2
- package/src/registry/ui/file-upload.tsx +0 -0
- package/src/registry/ui/floating-dock.tsx +0 -0
- package/src/registry/ui/form-field.tsx +91 -94
- package/src/registry/ui/google-analytics.tsx +38 -0
- package/src/registry/ui/google-tag-manager.tsx +64 -0
- package/src/registry/ui/hover-card.tsx +223 -223
- package/src/registry/ui/image.tsx +144 -147
- package/src/registry/ui/input-group.tsx +82 -85
- package/src/registry/ui/input.tsx +125 -125
- package/src/registry/ui/kbd.tsx +60 -63
- package/src/registry/ui/label.tsx +36 -37
- package/src/registry/ui/loading-spinner.tsx +108 -111
- package/src/registry/ui/map.tsx +0 -0
- package/src/registry/ui/marquee.tsx +2 -0
- package/src/registry/ui/menubar.tsx +246 -246
- package/src/registry/ui/meta-pixel.tsx +46 -0
- package/src/registry/ui/microsoft-clarity.tsx +33 -0
- package/src/registry/ui/native-select.tsx +49 -52
- package/src/registry/ui/otp-input.tsx +152 -155
- package/src/registry/ui/pagination.tsx +149 -152
- package/src/registry/ui/patterns.tsx +28 -0
- package/src/registry/ui/popover.tsx +226 -227
- package/src/registry/ui/progress.tsx +51 -52
- package/src/registry/ui/radio.tsx +99 -102
- package/src/registry/ui/resizable.tsx +314 -314
- package/src/registry/ui/scroll-animation.tsx +45 -0
- package/src/registry/ui/scroll-area.tsx +121 -122
- package/src/registry/ui/scroll-to-top.tsx +0 -0
- package/src/registry/ui/search.tsx +147 -150
- package/src/registry/ui/select.tsx +292 -293
- package/src/registry/ui/separator.tsx +46 -47
- package/src/registry/ui/sheet.tsx +6 -3
- package/src/registry/ui/sidebar.tsx +628 -628
- package/src/registry/ui/skeleton.tsx +26 -29
- package/src/registry/ui/slider.tsx +196 -197
- package/src/registry/ui/slot.tsx +69 -72
- package/src/registry/ui/star-rating.tsx +131 -134
- package/src/registry/ui/switch.tsx +72 -73
- package/src/registry/ui/table-of-contents.tsx +96 -96
- package/src/registry/ui/table.tsx +138 -139
- package/src/registry/ui/tabs.tsx +124 -125
- package/src/registry/ui/text.tsx +61 -64
- package/src/registry/ui/textarea.tsx +41 -42
- package/src/registry/ui/theme-switcher.tsx +66 -66
- package/src/registry/ui/tiktok-pixel.tsx +36 -0
- package/src/registry/ui/toast.tsx +97 -98
- package/src/registry/ui/toggle-group.tsx +129 -129
- package/src/registry/ui/toggle.tsx +72 -72
- package/src/registry/ui/tooltip.tsx +143 -144
- package/src/registry/ui/whatsapp.tsx +0 -0
|
@@ -1,515 +1,514 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
import * as React from "react"
|
|
4
|
-
import { cn } from "@/lib/utils"
|
|
5
|
-
|
|
6
|
-
interface CalendarContextValue {
|
|
7
|
-
currentMonth: Date
|
|
8
|
-
setCurrentMonth: (date: Date) => void
|
|
9
|
-
selectedDates: Date[]
|
|
10
|
-
onSelect: (date: Date) => void
|
|
11
|
-
mode: "single" | "multiple" | "range"
|
|
12
|
-
rangeStart: Date | null
|
|
13
|
-
hoveredDate: Date | null
|
|
14
|
-
setHoveredDate: (date: Date | null) => void
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
const CalendarContext = React.createContext<CalendarContextValue | null>(null)
|
|
18
|
-
|
|
19
|
-
export interface CalendarProps {
|
|
20
|
-
/** Selection mode */
|
|
21
|
-
mode?: "single" | "multiple" | "range"
|
|
22
|
-
/** Selected date(s) */
|
|
23
|
-
selected?: Date | Date[]
|
|
24
|
-
/** Callback when date is selected */
|
|
25
|
-
onSelect?: (date: Date | Date[] | undefined) => void
|
|
26
|
-
/** Default month to display */
|
|
27
|
-
defaultMonth?: Date
|
|
28
|
-
/** Minimum selectable date */
|
|
29
|
-
minDate?: Date
|
|
30
|
-
/** Maximum selectable date */
|
|
31
|
-
maxDate?: Date
|
|
32
|
-
/** Whether calendar is disabled */
|
|
33
|
-
disabled?: boolean
|
|
34
|
-
/** Size variant */
|
|
35
|
-
size?: "default" | "sm" | "xs" | "md" | "lg"
|
|
36
|
-
/** Number of months to display */
|
|
37
|
-
numberOfMonths?: number
|
|
38
|
-
className?: string
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
const DAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"]
|
|
42
|
-
const DAYS_SHORT = ["S", "M", "T", "W", "T", "F", "S"]
|
|
43
|
-
const MONTHS = [
|
|
44
|
-
"January", "February", "March", "April", "May", "June",
|
|
45
|
-
"July", "August", "September", "October", "November", "December"
|
|
46
|
-
]
|
|
47
|
-
const MONTHS_SHORT = [
|
|
48
|
-
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
|
49
|
-
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"
|
|
50
|
-
]
|
|
51
|
-
|
|
52
|
-
function getDaysInMonth(year: number, month: number): Date[] {
|
|
53
|
-
const days: Date[] = []
|
|
54
|
-
const firstDay = new Date(year, month, 1)
|
|
55
|
-
const lastDay = new Date(year, month + 1, 0)
|
|
56
|
-
|
|
57
|
-
// Add days from previous month to fill first week
|
|
58
|
-
const startPadding = firstDay.getDay()
|
|
59
|
-
for (let i = startPadding - 1; i >= 0; i--) {
|
|
60
|
-
days.push(new Date(year, month, -i))
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// Add days of current month
|
|
64
|
-
for (let d = 1; d <= lastDay.getDate(); d++) {
|
|
65
|
-
days.push(new Date(year, month, d))
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// Add days from next month to fill last week
|
|
69
|
-
const endPadding = 42 - days.length // 6 rows * 7 days
|
|
70
|
-
for (let i = 1; i <= endPadding; i++) {
|
|
71
|
-
days.push(new Date(year, month + 1, i))
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
return days
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function isSameDay(d1: Date | undefined | null, d2: Date | undefined | null): boolean {
|
|
78
|
-
if (!d1 || !d2) return false
|
|
79
|
-
return d1.getDate() === d2.getDate() &&
|
|
80
|
-
d1.getMonth() === d2.getMonth() &&
|
|
81
|
-
d1.getFullYear() === d2.getFullYear()
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function isInRange(date: Date, start: Date | null, end: Date | null): boolean {
|
|
85
|
-
if (!start || !end) return false
|
|
86
|
-
const d = date.getTime()
|
|
87
|
-
const s = start.getTime()
|
|
88
|
-
const e = end.getTime()
|
|
89
|
-
return d >= Math.min(s, e) && d <= Math.max(s, e)
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
type ViewMode = 'days' | 'months' | 'years'
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Calendar date picker component - Modern, Responsive, Advanced
|
|
96
|
-
*
|
|
97
|
-
* Features:
|
|
98
|
-
* - Fluid sizing (xs, sm, default, md, lg)
|
|
99
|
-
* - Range hover preview
|
|
100
|
-
* - Year/Month selection views
|
|
101
|
-
* - Animated transitions (CSS)
|
|
102
|
-
*/
|
|
103
|
-
const Calendar = React.forwardRef<HTMLDivElement, CalendarProps>(
|
|
104
|
-
(
|
|
105
|
-
(
|
|
106
|
-
{
|
|
107
|
-
mode = "single",
|
|
108
|
-
selected,
|
|
109
|
-
onSelect,
|
|
110
|
-
defaultMonth = new Date(),
|
|
111
|
-
minDate,
|
|
112
|
-
maxDate,
|
|
113
|
-
disabled,
|
|
114
|
-
size = "default",
|
|
115
|
-
className,
|
|
116
|
-
numberOfMonths = 1,
|
|
117
|
-
...props
|
|
118
|
-
},
|
|
119
|
-
ref
|
|
120
|
-
) => {
|
|
121
|
-
const [currentMonth, setCurrentMonth] = React.useState(defaultMonth)
|
|
122
|
-
const [view, setView] = React.useState<ViewMode>('days')
|
|
123
|
-
const [rangeStart, setRangeStart] = React.useState<Date | null>(null)
|
|
124
|
-
const [hoveredDate, setHoveredDate] = React.useState<Date | null>(null)
|
|
125
|
-
|
|
126
|
-
// Reset range start if mode changes or selection clears externally
|
|
127
|
-
React.useEffect(() => {
|
|
128
|
-
if (mode !== 'range' || !selected) {
|
|
129
|
-
setRangeStart(null)
|
|
130
|
-
}
|
|
131
|
-
}, [mode, selected])
|
|
132
|
-
|
|
133
|
-
const selectedDates = React.useMemo(() => {
|
|
134
|
-
if (!selected) return []
|
|
135
|
-
return Array.isArray(selected) ? selected : [selected]
|
|
136
|
-
}, [selected])
|
|
137
|
-
|
|
138
|
-
const handleSelect = (date: Date) => {
|
|
139
|
-
if (disabled) return
|
|
140
|
-
if (minDate && date < minDate) return
|
|
141
|
-
if (maxDate && date > maxDate) return
|
|
142
|
-
|
|
143
|
-
if (mode === "single") {
|
|
144
|
-
onSelect?.(date)
|
|
145
|
-
} else if (mode === "multiple") {
|
|
146
|
-
const exists = selectedDates.some(d => isSameDay(d, date))
|
|
147
|
-
if (exists) {
|
|
148
|
-
onSelect?.(selectedDates.filter(d => !isSameDay(d, date)))
|
|
149
|
-
} else {
|
|
150
|
-
onSelect?.([...selectedDates, date])
|
|
151
|
-
}
|
|
152
|
-
} else if (mode === "range") {
|
|
153
|
-
if (!rangeStart) {
|
|
154
|
-
setRangeStart(date)
|
|
155
|
-
onSelect?.([date]) // Partial selection
|
|
156
|
-
} else {
|
|
157
|
-
const start = rangeStart < date ? rangeStart : date
|
|
158
|
-
const end = rangeStart < date ? date : rangeStart
|
|
159
|
-
onSelect?.([start, end])
|
|
160
|
-
setRangeStart(null)
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
const navigatePrev = () => {
|
|
166
|
-
if (view === 'years') {
|
|
167
|
-
setCurrentMonth(new Date(currentMonth.getFullYear() - 12, currentMonth.getMonth(), 1))
|
|
168
|
-
} else if (view === 'months') {
|
|
169
|
-
setCurrentMonth(new Date(currentMonth.getFullYear() - 1, currentMonth.getMonth(), 1))
|
|
170
|
-
} else {
|
|
171
|
-
setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1, 1))
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
const navigateNext = () => {
|
|
176
|
-
if (view === 'years') {
|
|
177
|
-
setCurrentMonth(new Date(currentMonth.getFullYear() + 12, currentMonth.getMonth(), 1))
|
|
178
|
-
} else if (view === 'months') {
|
|
179
|
-
setCurrentMonth(new Date(currentMonth.getFullYear() + 1, currentMonth.getMonth(), 1))
|
|
180
|
-
} else {
|
|
181
|
-
setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 1))
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
// --- Sizing Configuration ---
|
|
186
|
-
const sizeStyles = {
|
|
187
|
-
xs: {
|
|
188
|
-
container: "p-2", // width handled by layout now
|
|
189
|
-
width: "w-[220px]",
|
|
190
|
-
header: "mb-2",
|
|
191
|
-
navBtn: "h-5 w-5",
|
|
192
|
-
icon: "h-2.5 w-2.5",
|
|
193
|
-
headerTitle: "text-[10px] font-medium",
|
|
194
|
-
grid: "gap-0.5",
|
|
195
|
-
dayName: "text-[9px] h-5",
|
|
196
|
-
cell: "text-[10px]",
|
|
197
|
-
yearGrid: "grid-cols-3 gap-1",
|
|
198
|
-
yearCell: "text-xs py-1.5",
|
|
199
|
-
radius: "rounded-sm",
|
|
200
|
-
},
|
|
201
|
-
sm: {
|
|
202
|
-
container: "p-3",
|
|
203
|
-
width: "w-[280px]",
|
|
204
|
-
header: "mb-3",
|
|
205
|
-
navBtn: "h-6 w-6",
|
|
206
|
-
icon: "h-3 w-3",
|
|
207
|
-
headerTitle: "text-xs font-semibold",
|
|
208
|
-
grid: "gap-1",
|
|
209
|
-
dayName: "text-[10px] h-6",
|
|
210
|
-
cell: "text-xs",
|
|
211
|
-
yearGrid: "grid-cols-3 gap-2",
|
|
212
|
-
yearCell: "text-xs py-2",
|
|
213
|
-
radius: "rounded-md",
|
|
214
|
-
},
|
|
215
|
-
default: {
|
|
216
|
-
container: "p-4",
|
|
217
|
-
width: "w-[320px]",
|
|
218
|
-
header: "mb-4",
|
|
219
|
-
navBtn: "h-8 w-8",
|
|
220
|
-
icon: "h-4 w-4",
|
|
221
|
-
headerTitle: "text-sm font-semibold",
|
|
222
|
-
grid: "gap-1",
|
|
223
|
-
dayName: "text-xs h-8",
|
|
224
|
-
cell: "text-sm",
|
|
225
|
-
yearGrid: "grid-cols-3 gap-3",
|
|
226
|
-
yearCell: "text-sm py-2.5",
|
|
227
|
-
radius: "rounded-md",
|
|
228
|
-
},
|
|
229
|
-
md: {
|
|
230
|
-
container: "p-5",
|
|
231
|
-
width: "w-[380px]",
|
|
232
|
-
header: "mb-5",
|
|
233
|
-
navBtn: "h-9 w-9",
|
|
234
|
-
icon: "h-4 w-4",
|
|
235
|
-
headerTitle: "text-base font-semibold",
|
|
236
|
-
grid: "gap-2",
|
|
237
|
-
dayName: "text-xs h-9",
|
|
238
|
-
cell: "text-base",
|
|
239
|
-
yearGrid: "grid-cols-4 gap-3",
|
|
240
|
-
yearCell: "text-base py-3",
|
|
241
|
-
radius: "rounded-lg",
|
|
242
|
-
},
|
|
243
|
-
lg: {
|
|
244
|
-
container: "p-6",
|
|
245
|
-
width: "w-[440px]",
|
|
246
|
-
header: "mb-6",
|
|
247
|
-
navBtn: "h-10 w-10",
|
|
248
|
-
icon: "h-5 w-5",
|
|
249
|
-
headerTitle: "text-lg font-bold",
|
|
250
|
-
grid: "gap-3",
|
|
251
|
-
dayName: "text-sm h-10",
|
|
252
|
-
cell: "text-lg",
|
|
253
|
-
yearGrid: "grid-cols-4 gap-4",
|
|
254
|
-
yearCell: "text-lg py-4",
|
|
255
|
-
radius: "rounded-xl",
|
|
256
|
-
},
|
|
257
|
-
}
|
|
258
|
-
const s = sizeStyles[size]
|
|
259
|
-
|
|
260
|
-
// --- Render Helpers ---
|
|
261
|
-
|
|
262
|
-
const renderMonthGrid = (monthOffset: number, isFirst: boolean, isLast: boolean) => {
|
|
263
|
-
const displayDate = new Date(currentMonth.getFullYear(), currentMonth.getMonth() + monthOffset, 1)
|
|
264
|
-
const days = getDaysInMonth(displayDate.getFullYear(), displayDate.getMonth())
|
|
265
|
-
|
|
266
|
-
// Calculate effective range end (either confirmed end or hover preview)
|
|
267
|
-
const rangeEnd = (mode === "range" && selectedDates.length === 2)
|
|
268
|
-
? selectedDates[1]
|
|
269
|
-
: (rangeStart && hoveredDate ? hoveredDate : null)
|
|
270
|
-
|
|
271
|
-
const label = size === "xs"
|
|
272
|
-
? `${MONTHS_SHORT[displayDate.getMonth()]} ${displayDate.getFullYear()}`
|
|
273
|
-
: `${MONTHS[displayDate.getMonth()]} ${displayDate.getFullYear()}`
|
|
274
|
-
|
|
275
|
-
const dayLabels = size === "xs" ? DAYS_SHORT : DAYS
|
|
276
|
-
|
|
277
|
-
return (
|
|
278
|
-
<div className={cn("relative", s.width)}>
|
|
279
|
-
<div className={cn("flex justify-between items-center", s.header)}>
|
|
280
|
-
{/* Prev Button - only show on first month */}
|
|
281
|
-
{isFirst && view === 'days' ? (
|
|
282
|
-
<button
|
|
283
|
-
type="button"
|
|
284
|
-
onClick={navigatePrev}
|
|
285
|
-
className={cn(
|
|
286
|
-
"inline-flex items-center justify-center rounded-md hover:bg-accent hover:text-accent-foreground active:scale-95 transition-all",
|
|
287
|
-
s.navBtn
|
|
288
|
-
)}
|
|
289
|
-
>
|
|
290
|
-
<svg className={s.icon} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
291
|
-
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
|
292
|
-
</svg>
|
|
293
|
-
</button>
|
|
294
|
-
) : <div className={s.navBtn} />}
|
|
295
|
-
|
|
296
|
-
{/* Month Title */}
|
|
297
|
-
<button
|
|
298
|
-
type="button"
|
|
299
|
-
onClick={() => setView(view === 'days' ? 'months' : view === 'months' ? 'years' : 'days')}
|
|
300
|
-
className={cn("hover:bg-accent px-2 py-1 rounded transition-colors font-semibold", s.headerTitle)}
|
|
301
|
-
>
|
|
302
|
-
{label}
|
|
303
|
-
</button>
|
|
304
|
-
|
|
305
|
-
{/* Next Button - only show on last month */}
|
|
306
|
-
{isLast && view === 'days' ? (
|
|
307
|
-
<button
|
|
308
|
-
type="button"
|
|
309
|
-
onClick={navigateNext}
|
|
310
|
-
className={cn(
|
|
311
|
-
"inline-flex items-center justify-center rounded-md hover:bg-accent hover:text-accent-foreground active:scale-95 transition-all",
|
|
312
|
-
s.navBtn
|
|
313
|
-
)}
|
|
314
|
-
>
|
|
315
|
-
<svg className={s.icon} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
316
|
-
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
|
317
|
-
</svg>
|
|
318
|
-
</button>
|
|
319
|
-
) : <div className={s.navBtn} />}
|
|
320
|
-
</div>
|
|
321
|
-
|
|
322
|
-
{/* Day Names */}
|
|
323
|
-
<div className={cn("grid grid-cols-7 mb-2", s.grid)}>
|
|
324
|
-
{dayLabels.map((day, i) => (
|
|
325
|
-
<div key={i} className={cn("flex justify-center items-center text-muted-foreground font-medium", s.dayName)}>
|
|
326
|
-
{day}
|
|
327
|
-
</div>
|
|
328
|
-
))}
|
|
329
|
-
</div>
|
|
330
|
-
|
|
331
|
-
{/* Days Grid */}
|
|
332
|
-
<div className={cn("grid grid-cols-7", s.grid)} role="grid">
|
|
333
|
-
{days.map((date, index) => {
|
|
334
|
-
const isCurrentMonth = date.getMonth() === displayDate.getMonth()
|
|
335
|
-
const isSelected = selectedDates.some(d => isSameDay(d, date))
|
|
336
|
-
const isRangeStart = rangeStart && isSameDay(date, rangeStart)
|
|
337
|
-
// A date is effectively the "end" if it's the actual confirmed end OR the hovered end
|
|
338
|
-
// (but only if we are currently selecting a range)
|
|
339
|
-
const isRangeEnd = mode === "range" && rangeEnd && isSameDay(date, rangeEnd)
|
|
340
|
-
|
|
341
|
-
// Check if in range of (start -> end/hover)
|
|
342
|
-
const inRange = mode === "range" && isInRange(date, rangeStart || selectedDates[0], rangeEnd)
|
|
343
|
-
|
|
344
|
-
const isToday = isSameDay(date, new Date())
|
|
345
|
-
const isDisabled = disabled || (minDate && date < minDate) || (maxDate && date > maxDate)
|
|
346
|
-
|
|
347
|
-
return (
|
|
348
|
-
<button
|
|
349
|
-
key={index}
|
|
350
|
-
type="button"
|
|
351
|
-
onClick={() => handleSelect(date)}
|
|
352
|
-
onMouseEnter={() => setHoveredDate(date)}
|
|
353
|
-
onMouseLeave={() => setHoveredDate(null)}
|
|
354
|
-
disabled={isDisabled}
|
|
355
|
-
className={cn(
|
|
356
|
-
"relative p-0 text-center focus-within:relative focus-within:z-20",
|
|
357
|
-
"aspect-square w-full flex items-center justify-center transition-all",
|
|
358
|
-
s.cell,
|
|
359
|
-
s.radius, // Apply base radius
|
|
360
|
-
// Base states
|
|
361
|
-
!isCurrentMonth && "text-muted-foreground/30",
|
|
362
|
-
isCurrentMonth && "text-foreground font-normal",
|
|
363
|
-
isToday && !isSelected && "text-accent-foreground bg-accent/30 font-semibold ring-1 ring-inset ring-accent-foreground/20",
|
|
364
|
-
isDisabled && "opacity-30 cursor-not-allowed hover:bg-transparent",
|
|
365
|
-
|
|
366
|
-
// Hover states (only if enabled)
|
|
367
|
-
!isDisabled && !isSelected && !inRange && isCurrentMonth && "hover:bg-accent hover:text-accent-foreground",
|
|
368
|
-
|
|
369
|
-
// Selection states
|
|
370
|
-
isSelected && "bg-primary text-primary-foreground hover:bg-primary shadow-sm font-semibold z-10",
|
|
371
|
-
|
|
372
|
-
// Range styling
|
|
373
|
-
(isRangeStart || isRangeEnd) && "bg-primary text-primary-foreground hover:bg-primary shadow-sm font-semibold z-10",
|
|
374
|
-
inRange && !isSelected && "bg-accent/40 text-accent-foreground font-medium hover:bg-accent/60",
|
|
375
|
-
|
|
376
|
-
// Range rounding fix (connect bars)
|
|
377
|
-
inRange && "rounded-none",
|
|
378
|
-
isRangeStart && rangeEnd && "rounded-r-none",
|
|
379
|
-
isRangeEnd && rangeStart && "rounded-l-none",
|
|
380
|
-
|
|
381
|
-
// Specific case: Start == End (Single day range)
|
|
382
|
-
isRangeStart && isRangeEnd && s.radius
|
|
383
|
-
)}
|
|
384
|
-
>
|
|
385
|
-
<time dateTime={date.toISOString().split('T')[0]}>
|
|
386
|
-
{date.getDate()}
|
|
387
|
-
</time>
|
|
388
|
-
</button>
|
|
389
|
-
)
|
|
390
|
-
})}
|
|
391
|
-
</div>
|
|
392
|
-
</div>
|
|
393
|
-
)
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
const renderMonths = () => {
|
|
397
|
-
const currentYear = currentMonth.getFullYear()
|
|
398
|
-
return (
|
|
399
|
-
<div className={cn("grid w-full", s.yearGrid)}>
|
|
400
|
-
{MONTHS_SHORT.map((month, index) => {
|
|
401
|
-
const isCurrent = new Date().getMonth() === index && new Date().getFullYear() === currentYear
|
|
402
|
-
const isSelected = currentMonth.getMonth() === index
|
|
403
|
-
return (
|
|
404
|
-
<button
|
|
405
|
-
key={month}
|
|
406
|
-
type="button"
|
|
407
|
-
onClick={() => {
|
|
408
|
-
setCurrentMonth(new Date(currentYear, index, 1))
|
|
409
|
-
setView('days')
|
|
410
|
-
}}
|
|
411
|
-
className={cn(
|
|
412
|
-
"flex items-center justify-center transition-colors hover:bg-accent rounded-md font-medium",
|
|
413
|
-
s.yearCell,
|
|
414
|
-
isCurrent && "border border-primary text-primary",
|
|
415
|
-
isSelected && "bg-primary text-primary-foreground hover:bg-primary"
|
|
416
|
-
)}
|
|
417
|
-
>
|
|
418
|
-
{size === 'xs' ? month.charAt(0) : month}
|
|
419
|
-
</button>
|
|
420
|
-
)
|
|
421
|
-
})}
|
|
422
|
-
</div>
|
|
423
|
-
)
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
const renderYears = () => {
|
|
427
|
-
const bgYear = currentMonth.getFullYear()
|
|
428
|
-
const startYear = bgYear - (bgYear % 12)
|
|
429
|
-
const years = Array.from({ length: 12 }, (_, i) => startYear + i)
|
|
430
|
-
|
|
431
|
-
return (
|
|
432
|
-
<div className={cn("grid w-full", s.yearGrid)}>
|
|
433
|
-
{years.map((year) => {
|
|
434
|
-
const isCurrent = new Date().getFullYear() === year
|
|
435
|
-
const isSelected = currentMonth.getFullYear() === year
|
|
436
|
-
return (
|
|
437
|
-
<button
|
|
438
|
-
key={year}
|
|
439
|
-
type="button"
|
|
440
|
-
onClick={() => {
|
|
441
|
-
setCurrentMonth(new Date(year, currentMonth.getMonth(), 1))
|
|
442
|
-
setView('months')
|
|
443
|
-
}}
|
|
444
|
-
className={cn(
|
|
445
|
-
"flex items-center justify-center transition-colors hover:bg-accent rounded-md font-medium",
|
|
446
|
-
s.yearCell,
|
|
447
|
-
isCurrent && "border border-primary text-primary",
|
|
448
|
-
isSelected && "bg-primary text-primary-foreground hover:bg-primary"
|
|
449
|
-
)}
|
|
450
|
-
>
|
|
451
|
-
{year}
|
|
452
|
-
</button>
|
|
453
|
-
)
|
|
454
|
-
})}
|
|
455
|
-
</div>
|
|
456
|
-
)
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
const monthsToRender = Array.from({ length: numberOfMonths }, (_, i) => i)
|
|
460
|
-
|
|
461
|
-
return (
|
|
462
|
-
<div
|
|
463
|
-
ref={ref}
|
|
464
|
-
className={cn(
|
|
465
|
-
"bg-background border rounded-lg shadow-sm transition-all duration-200 inline-block relative",
|
|
466
|
-
s.container,
|
|
467
|
-
className
|
|
468
|
-
)}
|
|
469
|
-
role="application"
|
|
470
|
-
aria-label="Calendar"
|
|
471
|
-
{...props}
|
|
472
|
-
>
|
|
473
|
-
{/* Navigation Overlays */}
|
|
474
|
-
|
|
475
|
-
<div className={cn("flex gap-8 relative")}> {/* Gap between months */}
|
|
476
|
-
{view === 'days' ? (
|
|
477
|
-
monthsToRender.map(offset => (
|
|
478
|
-
<React.Fragment key={offset}>
|
|
479
|
-
{renderMonthGrid(offset, offset === 0, offset === numberOfMonths - 1)}
|
|
480
|
-
</React.Fragment>
|
|
481
|
-
))
|
|
482
|
-
) : (
|
|
483
|
-
<div className={cn("w-full", s.width)}>
|
|
484
|
-
{/* Render View Controls Header */}
|
|
485
|
-
<div className={cn("flex justify-center items-center mb-4 relative")}>
|
|
486
|
-
<div className={cn("font-semibold", s.headerTitle)}>
|
|
487
|
-
{view === 'months' ? currentMonth.getFullYear() : `${currentMonth.getFullYear() - (currentMonth.getFullYear() % 12)} - ${currentMonth.getFullYear() - (currentMonth.getFullYear() % 12) + 11}`}
|
|
488
|
-
</div>
|
|
489
|
-
{/* Navigation for views */}
|
|
490
|
-
<div className="absolute inset-0 flex justify-between items-center">
|
|
491
|
-
<button onClick={navigatePrev} className={cn("p-1 rounded-md border hover:bg-accent", s.navBtn, "flex items-center justify-center")}>
|
|
492
|
-
<svg className={s.icon} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /></svg>
|
|
493
|
-
</button>
|
|
494
|
-
<button onClick={navigateNext} className={cn("p-1 rounded-md border hover:bg-accent", s.navBtn, "flex items-center justify-center")}>
|
|
495
|
-
<svg className={s.icon} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /></svg>
|
|
496
|
-
</button>
|
|
497
|
-
</div>
|
|
498
|
-
</div>
|
|
499
|
-
|
|
500
|
-
<div className="h-[240px]">
|
|
501
|
-
{view === 'months' && renderMonths()}
|
|
502
|
-
{view === 'years' && renderYears()}
|
|
503
|
-
</div>
|
|
504
|
-
</div>
|
|
505
|
-
)}
|
|
506
|
-
</div>
|
|
507
|
-
</div>
|
|
508
|
-
)
|
|
509
|
-
}
|
|
510
|
-
))
|
|
511
|
-
|
|
512
|
-
Calendar.displayName = "Calendar"
|
|
513
|
-
|
|
514
|
-
export { Calendar }
|
|
515
|
-
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { cn } from "@/lib/utils"
|
|
5
|
+
|
|
6
|
+
interface CalendarContextValue {
|
|
7
|
+
currentMonth: Date
|
|
8
|
+
setCurrentMonth: (date: Date) => void
|
|
9
|
+
selectedDates: Date[]
|
|
10
|
+
onSelect: (date: Date) => void
|
|
11
|
+
mode: "single" | "multiple" | "range"
|
|
12
|
+
rangeStart: Date | null
|
|
13
|
+
hoveredDate: Date | null
|
|
14
|
+
setHoveredDate: (date: Date | null) => void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const CalendarContext = React.createContext<CalendarContextValue | null>(null)
|
|
18
|
+
|
|
19
|
+
export interface CalendarProps {
|
|
20
|
+
/** Selection mode */
|
|
21
|
+
mode?: "single" | "multiple" | "range"
|
|
22
|
+
/** Selected date(s) */
|
|
23
|
+
selected?: Date | Date[]
|
|
24
|
+
/** Callback when date is selected */
|
|
25
|
+
onSelect?: (date: Date | Date[] | undefined) => void
|
|
26
|
+
/** Default month to display */
|
|
27
|
+
defaultMonth?: Date
|
|
28
|
+
/** Minimum selectable date */
|
|
29
|
+
minDate?: Date
|
|
30
|
+
/** Maximum selectable date */
|
|
31
|
+
maxDate?: Date
|
|
32
|
+
/** Whether calendar is disabled */
|
|
33
|
+
disabled?: boolean
|
|
34
|
+
/** Size variant */
|
|
35
|
+
size?: "default" | "sm" | "xs" | "md" | "lg"
|
|
36
|
+
/** Number of months to display */
|
|
37
|
+
numberOfMonths?: number
|
|
38
|
+
className?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const DAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"]
|
|
42
|
+
const DAYS_SHORT = ["S", "M", "T", "W", "T", "F", "S"]
|
|
43
|
+
const MONTHS = [
|
|
44
|
+
"January", "February", "March", "April", "May", "June",
|
|
45
|
+
"July", "August", "September", "October", "November", "December"
|
|
46
|
+
]
|
|
47
|
+
const MONTHS_SHORT = [
|
|
48
|
+
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
|
49
|
+
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
function getDaysInMonth(year: number, month: number): Date[] {
|
|
53
|
+
const days: Date[] = []
|
|
54
|
+
const firstDay = new Date(year, month, 1)
|
|
55
|
+
const lastDay = new Date(year, month + 1, 0)
|
|
56
|
+
|
|
57
|
+
// Add days from previous month to fill first week
|
|
58
|
+
const startPadding = firstDay.getDay()
|
|
59
|
+
for (let i = startPadding - 1; i >= 0; i--) {
|
|
60
|
+
days.push(new Date(year, month, -i))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Add days of current month
|
|
64
|
+
for (let d = 1; d <= lastDay.getDate(); d++) {
|
|
65
|
+
days.push(new Date(year, month, d))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Add days from next month to fill last week
|
|
69
|
+
const endPadding = 42 - days.length // 6 rows * 7 days
|
|
70
|
+
for (let i = 1; i <= endPadding; i++) {
|
|
71
|
+
days.push(new Date(year, month + 1, i))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return days
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isSameDay(d1: Date | undefined | null, d2: Date | undefined | null): boolean {
|
|
78
|
+
if (!d1 || !d2) return false
|
|
79
|
+
return d1.getDate() === d2.getDate() &&
|
|
80
|
+
d1.getMonth() === d2.getMonth() &&
|
|
81
|
+
d1.getFullYear() === d2.getFullYear()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function isInRange(date: Date, start: Date | null, end: Date | null): boolean {
|
|
85
|
+
if (!start || !end) return false
|
|
86
|
+
const d = date.getTime()
|
|
87
|
+
const s = start.getTime()
|
|
88
|
+
const e = end.getTime()
|
|
89
|
+
return d >= Math.min(s, e) && d <= Math.max(s, e)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
type ViewMode = 'days' | 'months' | 'years'
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Calendar date picker component - Modern, Responsive, Advanced
|
|
96
|
+
*
|
|
97
|
+
* Features:
|
|
98
|
+
* - Fluid sizing (xs, sm, default, md, lg)
|
|
99
|
+
* - Range hover preview
|
|
100
|
+
* - Year/Month selection views
|
|
101
|
+
* - Animated transitions (CSS)
|
|
102
|
+
*/
|
|
103
|
+
const Calendar = React.forwardRef<HTMLDivElement, CalendarProps>(
|
|
104
|
+
(
|
|
105
|
+
(
|
|
106
|
+
{
|
|
107
|
+
mode = "single",
|
|
108
|
+
selected,
|
|
109
|
+
onSelect,
|
|
110
|
+
defaultMonth = new Date(),
|
|
111
|
+
minDate,
|
|
112
|
+
maxDate,
|
|
113
|
+
disabled,
|
|
114
|
+
size = "default",
|
|
115
|
+
className,
|
|
116
|
+
numberOfMonths = 1,
|
|
117
|
+
...props
|
|
118
|
+
},
|
|
119
|
+
ref
|
|
120
|
+
) => {
|
|
121
|
+
const [currentMonth, setCurrentMonth] = React.useState(defaultMonth)
|
|
122
|
+
const [view, setView] = React.useState<ViewMode>('days')
|
|
123
|
+
const [rangeStart, setRangeStart] = React.useState<Date | null>(null)
|
|
124
|
+
const [hoveredDate, setHoveredDate] = React.useState<Date | null>(null)
|
|
125
|
+
|
|
126
|
+
// Reset range start if mode changes or selection clears externally
|
|
127
|
+
React.useEffect(() => {
|
|
128
|
+
if (mode !== 'range' || !selected) {
|
|
129
|
+
setRangeStart(null)
|
|
130
|
+
}
|
|
131
|
+
}, [mode, selected])
|
|
132
|
+
|
|
133
|
+
const selectedDates = React.useMemo(() => {
|
|
134
|
+
if (!selected) return []
|
|
135
|
+
return Array.isArray(selected) ? selected : [selected]
|
|
136
|
+
}, [selected])
|
|
137
|
+
|
|
138
|
+
const handleSelect = (date: Date) => {
|
|
139
|
+
if (disabled) return
|
|
140
|
+
if (minDate && date < minDate) return
|
|
141
|
+
if (maxDate && date > maxDate) return
|
|
142
|
+
|
|
143
|
+
if (mode === "single") {
|
|
144
|
+
onSelect?.(date)
|
|
145
|
+
} else if (mode === "multiple") {
|
|
146
|
+
const exists = selectedDates.some(d => isSameDay(d, date))
|
|
147
|
+
if (exists) {
|
|
148
|
+
onSelect?.(selectedDates.filter(d => !isSameDay(d, date)))
|
|
149
|
+
} else {
|
|
150
|
+
onSelect?.([...selectedDates, date])
|
|
151
|
+
}
|
|
152
|
+
} else if (mode === "range") {
|
|
153
|
+
if (!rangeStart) {
|
|
154
|
+
setRangeStart(date)
|
|
155
|
+
onSelect?.([date]) // Partial selection
|
|
156
|
+
} else {
|
|
157
|
+
const start = rangeStart < date ? rangeStart : date
|
|
158
|
+
const end = rangeStart < date ? date : rangeStart
|
|
159
|
+
onSelect?.([start, end])
|
|
160
|
+
setRangeStart(null)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const navigatePrev = () => {
|
|
166
|
+
if (view === 'years') {
|
|
167
|
+
setCurrentMonth(new Date(currentMonth.getFullYear() - 12, currentMonth.getMonth(), 1))
|
|
168
|
+
} else if (view === 'months') {
|
|
169
|
+
setCurrentMonth(new Date(currentMonth.getFullYear() - 1, currentMonth.getMonth(), 1))
|
|
170
|
+
} else {
|
|
171
|
+
setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1, 1))
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const navigateNext = () => {
|
|
176
|
+
if (view === 'years') {
|
|
177
|
+
setCurrentMonth(new Date(currentMonth.getFullYear() + 12, currentMonth.getMonth(), 1))
|
|
178
|
+
} else if (view === 'months') {
|
|
179
|
+
setCurrentMonth(new Date(currentMonth.getFullYear() + 1, currentMonth.getMonth(), 1))
|
|
180
|
+
} else {
|
|
181
|
+
setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 1))
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// --- Sizing Configuration ---
|
|
186
|
+
const sizeStyles = {
|
|
187
|
+
xs: {
|
|
188
|
+
container: "p-2", // width handled by layout now
|
|
189
|
+
width: "w-[220px]",
|
|
190
|
+
header: "mb-2",
|
|
191
|
+
navBtn: "h-5 w-5",
|
|
192
|
+
icon: "h-2.5 w-2.5",
|
|
193
|
+
headerTitle: "text-[10px] font-medium",
|
|
194
|
+
grid: "gap-0.5",
|
|
195
|
+
dayName: "text-[9px] h-5",
|
|
196
|
+
cell: "text-[10px]",
|
|
197
|
+
yearGrid: "grid-cols-3 gap-1",
|
|
198
|
+
yearCell: "text-xs py-1.5",
|
|
199
|
+
radius: "rounded-sm",
|
|
200
|
+
},
|
|
201
|
+
sm: {
|
|
202
|
+
container: "p-3",
|
|
203
|
+
width: "w-[280px]",
|
|
204
|
+
header: "mb-3",
|
|
205
|
+
navBtn: "h-6 w-6",
|
|
206
|
+
icon: "h-3 w-3",
|
|
207
|
+
headerTitle: "text-xs font-semibold",
|
|
208
|
+
grid: "gap-1",
|
|
209
|
+
dayName: "text-[10px] h-6",
|
|
210
|
+
cell: "text-xs",
|
|
211
|
+
yearGrid: "grid-cols-3 gap-2",
|
|
212
|
+
yearCell: "text-xs py-2",
|
|
213
|
+
radius: "rounded-md",
|
|
214
|
+
},
|
|
215
|
+
default: {
|
|
216
|
+
container: "p-4",
|
|
217
|
+
width: "w-[320px]",
|
|
218
|
+
header: "mb-4",
|
|
219
|
+
navBtn: "h-8 w-8",
|
|
220
|
+
icon: "h-4 w-4",
|
|
221
|
+
headerTitle: "text-sm font-semibold",
|
|
222
|
+
grid: "gap-1",
|
|
223
|
+
dayName: "text-xs h-8",
|
|
224
|
+
cell: "text-sm",
|
|
225
|
+
yearGrid: "grid-cols-3 gap-3",
|
|
226
|
+
yearCell: "text-sm py-2.5",
|
|
227
|
+
radius: "rounded-md",
|
|
228
|
+
},
|
|
229
|
+
md: {
|
|
230
|
+
container: "p-5",
|
|
231
|
+
width: "w-[380px]",
|
|
232
|
+
header: "mb-5",
|
|
233
|
+
navBtn: "h-9 w-9",
|
|
234
|
+
icon: "h-4 w-4",
|
|
235
|
+
headerTitle: "text-base font-semibold",
|
|
236
|
+
grid: "gap-2",
|
|
237
|
+
dayName: "text-xs h-9",
|
|
238
|
+
cell: "text-base",
|
|
239
|
+
yearGrid: "grid-cols-4 gap-3",
|
|
240
|
+
yearCell: "text-base py-3",
|
|
241
|
+
radius: "rounded-lg",
|
|
242
|
+
},
|
|
243
|
+
lg: {
|
|
244
|
+
container: "p-6",
|
|
245
|
+
width: "w-[440px]",
|
|
246
|
+
header: "mb-6",
|
|
247
|
+
navBtn: "h-10 w-10",
|
|
248
|
+
icon: "h-5 w-5",
|
|
249
|
+
headerTitle: "text-lg font-bold",
|
|
250
|
+
grid: "gap-3",
|
|
251
|
+
dayName: "text-sm h-10",
|
|
252
|
+
cell: "text-lg",
|
|
253
|
+
yearGrid: "grid-cols-4 gap-4",
|
|
254
|
+
yearCell: "text-lg py-4",
|
|
255
|
+
radius: "rounded-xl",
|
|
256
|
+
},
|
|
257
|
+
}
|
|
258
|
+
const s = sizeStyles[size]
|
|
259
|
+
|
|
260
|
+
// --- Render Helpers ---
|
|
261
|
+
|
|
262
|
+
const renderMonthGrid = (monthOffset: number, isFirst: boolean, isLast: boolean) => {
|
|
263
|
+
const displayDate = new Date(currentMonth.getFullYear(), currentMonth.getMonth() + monthOffset, 1)
|
|
264
|
+
const days = getDaysInMonth(displayDate.getFullYear(), displayDate.getMonth())
|
|
265
|
+
|
|
266
|
+
// Calculate effective range end (either confirmed end or hover preview)
|
|
267
|
+
const rangeEnd = (mode === "range" && selectedDates.length === 2)
|
|
268
|
+
? selectedDates[1]
|
|
269
|
+
: (rangeStart && hoveredDate ? hoveredDate : null)
|
|
270
|
+
|
|
271
|
+
const label = size === "xs"
|
|
272
|
+
? `${MONTHS_SHORT[displayDate.getMonth()]} ${displayDate.getFullYear()}`
|
|
273
|
+
: `${MONTHS[displayDate.getMonth()]} ${displayDate.getFullYear()}`
|
|
274
|
+
|
|
275
|
+
const dayLabels = size === "xs" ? DAYS_SHORT : DAYS
|
|
276
|
+
|
|
277
|
+
return (
|
|
278
|
+
<div className={cn("relative", s.width)}>
|
|
279
|
+
<div className={cn("flex justify-between items-center", s.header)}>
|
|
280
|
+
{/* Prev Button - only show on first month */}
|
|
281
|
+
{isFirst && view === 'days' ? (
|
|
282
|
+
<button
|
|
283
|
+
type="button"
|
|
284
|
+
onClick={navigatePrev}
|
|
285
|
+
className={cn(
|
|
286
|
+
"inline-flex items-center justify-center rounded-md hover:bg-accent hover:text-accent-foreground active:scale-95 transition-all",
|
|
287
|
+
s.navBtn
|
|
288
|
+
)}
|
|
289
|
+
>
|
|
290
|
+
<svg className={s.icon} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
291
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
|
292
|
+
</svg>
|
|
293
|
+
</button>
|
|
294
|
+
) : <div className={s.navBtn} />}
|
|
295
|
+
|
|
296
|
+
{/* Month Title */}
|
|
297
|
+
<button
|
|
298
|
+
type="button"
|
|
299
|
+
onClick={() => setView(view === 'days' ? 'months' : view === 'months' ? 'years' : 'days')}
|
|
300
|
+
className={cn("hover:bg-accent px-2 py-1 rounded transition-colors font-semibold", s.headerTitle)}
|
|
301
|
+
>
|
|
302
|
+
{label}
|
|
303
|
+
</button>
|
|
304
|
+
|
|
305
|
+
{/* Next Button - only show on last month */}
|
|
306
|
+
{isLast && view === 'days' ? (
|
|
307
|
+
<button
|
|
308
|
+
type="button"
|
|
309
|
+
onClick={navigateNext}
|
|
310
|
+
className={cn(
|
|
311
|
+
"inline-flex items-center justify-center rounded-md hover:bg-accent hover:text-accent-foreground active:scale-95 transition-all",
|
|
312
|
+
s.navBtn
|
|
313
|
+
)}
|
|
314
|
+
>
|
|
315
|
+
<svg className={s.icon} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
316
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
|
317
|
+
</svg>
|
|
318
|
+
</button>
|
|
319
|
+
) : <div className={s.navBtn} />}
|
|
320
|
+
</div>
|
|
321
|
+
|
|
322
|
+
{/* Day Names */}
|
|
323
|
+
<div className={cn("grid grid-cols-7 mb-2", s.grid)}>
|
|
324
|
+
{dayLabels.map((day, i) => (
|
|
325
|
+
<div key={i} className={cn("flex justify-center items-center text-muted-foreground font-medium", s.dayName)}>
|
|
326
|
+
{day}
|
|
327
|
+
</div>
|
|
328
|
+
))}
|
|
329
|
+
</div>
|
|
330
|
+
|
|
331
|
+
{/* Days Grid */}
|
|
332
|
+
<div className={cn("grid grid-cols-7", s.grid)} role="grid">
|
|
333
|
+
{days.map((date, index) => {
|
|
334
|
+
const isCurrentMonth = date.getMonth() === displayDate.getMonth()
|
|
335
|
+
const isSelected = selectedDates.some(d => isSameDay(d, date))
|
|
336
|
+
const isRangeStart = rangeStart && isSameDay(date, rangeStart)
|
|
337
|
+
// A date is effectively the "end" if it's the actual confirmed end OR the hovered end
|
|
338
|
+
// (but only if we are currently selecting a range)
|
|
339
|
+
const isRangeEnd = mode === "range" && rangeEnd && isSameDay(date, rangeEnd)
|
|
340
|
+
|
|
341
|
+
// Check if in range of (start -> end/hover)
|
|
342
|
+
const inRange = mode === "range" && isInRange(date, rangeStart || selectedDates[0], rangeEnd)
|
|
343
|
+
|
|
344
|
+
const isToday = isSameDay(date, new Date())
|
|
345
|
+
const isDisabled = disabled || (minDate && date < minDate) || (maxDate && date > maxDate)
|
|
346
|
+
|
|
347
|
+
return (
|
|
348
|
+
<button
|
|
349
|
+
key={index}
|
|
350
|
+
type="button"
|
|
351
|
+
onClick={() => handleSelect(date)}
|
|
352
|
+
onMouseEnter={() => setHoveredDate(date)}
|
|
353
|
+
onMouseLeave={() => setHoveredDate(null)}
|
|
354
|
+
disabled={isDisabled}
|
|
355
|
+
className={cn(
|
|
356
|
+
"relative p-0 text-center focus-within:relative focus-within:z-20",
|
|
357
|
+
"aspect-square w-full flex items-center justify-center transition-all",
|
|
358
|
+
s.cell,
|
|
359
|
+
s.radius, // Apply base radius
|
|
360
|
+
// Base states
|
|
361
|
+
!isCurrentMonth && "text-muted-foreground/30",
|
|
362
|
+
isCurrentMonth && "text-foreground font-normal",
|
|
363
|
+
isToday && !isSelected && "text-accent-foreground bg-accent/30 font-semibold ring-1 ring-inset ring-accent-foreground/20",
|
|
364
|
+
isDisabled && "opacity-30 cursor-not-allowed hover:bg-transparent",
|
|
365
|
+
|
|
366
|
+
// Hover states (only if enabled)
|
|
367
|
+
!isDisabled && !isSelected && !inRange && isCurrentMonth && "hover:bg-accent hover:text-accent-foreground",
|
|
368
|
+
|
|
369
|
+
// Selection states
|
|
370
|
+
isSelected && "bg-primary text-primary-foreground hover:bg-primary shadow-sm font-semibold z-10",
|
|
371
|
+
|
|
372
|
+
// Range styling
|
|
373
|
+
(isRangeStart || isRangeEnd) && "bg-primary text-primary-foreground hover:bg-primary shadow-sm font-semibold z-10",
|
|
374
|
+
inRange && !isSelected && "bg-accent/40 text-accent-foreground font-medium hover:bg-accent/60",
|
|
375
|
+
|
|
376
|
+
// Range rounding fix (connect bars)
|
|
377
|
+
inRange && "rounded-none",
|
|
378
|
+
isRangeStart && rangeEnd && "rounded-r-none",
|
|
379
|
+
isRangeEnd && rangeStart && "rounded-l-none",
|
|
380
|
+
|
|
381
|
+
// Specific case: Start == End (Single day range)
|
|
382
|
+
isRangeStart && isRangeEnd && s.radius
|
|
383
|
+
)}
|
|
384
|
+
>
|
|
385
|
+
<time dateTime={date.toISOString().split('T')[0]}>
|
|
386
|
+
{date.getDate()}
|
|
387
|
+
</time>
|
|
388
|
+
</button>
|
|
389
|
+
)
|
|
390
|
+
})}
|
|
391
|
+
</div>
|
|
392
|
+
</div>
|
|
393
|
+
)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const renderMonths = () => {
|
|
397
|
+
const currentYear = currentMonth.getFullYear()
|
|
398
|
+
return (
|
|
399
|
+
<div className={cn("grid w-full", s.yearGrid)}>
|
|
400
|
+
{MONTHS_SHORT.map((month, index) => {
|
|
401
|
+
const isCurrent = new Date().getMonth() === index && new Date().getFullYear() === currentYear
|
|
402
|
+
const isSelected = currentMonth.getMonth() === index
|
|
403
|
+
return (
|
|
404
|
+
<button
|
|
405
|
+
key={month}
|
|
406
|
+
type="button"
|
|
407
|
+
onClick={() => {
|
|
408
|
+
setCurrentMonth(new Date(currentYear, index, 1))
|
|
409
|
+
setView('days')
|
|
410
|
+
}}
|
|
411
|
+
className={cn(
|
|
412
|
+
"flex items-center justify-center transition-colors hover:bg-accent rounded-md font-medium",
|
|
413
|
+
s.yearCell,
|
|
414
|
+
isCurrent && "border border-primary text-primary",
|
|
415
|
+
isSelected && "bg-primary text-primary-foreground hover:bg-primary"
|
|
416
|
+
)}
|
|
417
|
+
>
|
|
418
|
+
{size === 'xs' ? month.charAt(0) : month}
|
|
419
|
+
</button>
|
|
420
|
+
)
|
|
421
|
+
})}
|
|
422
|
+
</div>
|
|
423
|
+
)
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const renderYears = () => {
|
|
427
|
+
const bgYear = currentMonth.getFullYear()
|
|
428
|
+
const startYear = bgYear - (bgYear % 12)
|
|
429
|
+
const years = Array.from({ length: 12 }, (_, i) => startYear + i)
|
|
430
|
+
|
|
431
|
+
return (
|
|
432
|
+
<div className={cn("grid w-full", s.yearGrid)}>
|
|
433
|
+
{years.map((year) => {
|
|
434
|
+
const isCurrent = new Date().getFullYear() === year
|
|
435
|
+
const isSelected = currentMonth.getFullYear() === year
|
|
436
|
+
return (
|
|
437
|
+
<button
|
|
438
|
+
key={year}
|
|
439
|
+
type="button"
|
|
440
|
+
onClick={() => {
|
|
441
|
+
setCurrentMonth(new Date(year, currentMonth.getMonth(), 1))
|
|
442
|
+
setView('months')
|
|
443
|
+
}}
|
|
444
|
+
className={cn(
|
|
445
|
+
"flex items-center justify-center transition-colors hover:bg-accent rounded-md font-medium",
|
|
446
|
+
s.yearCell,
|
|
447
|
+
isCurrent && "border border-primary text-primary",
|
|
448
|
+
isSelected && "bg-primary text-primary-foreground hover:bg-primary"
|
|
449
|
+
)}
|
|
450
|
+
>
|
|
451
|
+
{year}
|
|
452
|
+
</button>
|
|
453
|
+
)
|
|
454
|
+
})}
|
|
455
|
+
</div>
|
|
456
|
+
)
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const monthsToRender = Array.from({ length: numberOfMonths }, (_, i) => i)
|
|
460
|
+
|
|
461
|
+
return (
|
|
462
|
+
<div
|
|
463
|
+
ref={ref}
|
|
464
|
+
className={cn(
|
|
465
|
+
"bg-background border rounded-lg shadow-sm transition-all duration-200 inline-block relative",
|
|
466
|
+
s.container,
|
|
467
|
+
className
|
|
468
|
+
)}
|
|
469
|
+
role="application"
|
|
470
|
+
aria-label="Calendar"
|
|
471
|
+
{...props}
|
|
472
|
+
>
|
|
473
|
+
{/* Navigation Overlays */}
|
|
474
|
+
|
|
475
|
+
<div className={cn("flex gap-8 relative")}> {/* Gap between months */}
|
|
476
|
+
{view === 'days' ? (
|
|
477
|
+
monthsToRender.map(offset => (
|
|
478
|
+
<React.Fragment key={offset}>
|
|
479
|
+
{renderMonthGrid(offset, offset === 0, offset === numberOfMonths - 1)}
|
|
480
|
+
</React.Fragment>
|
|
481
|
+
))
|
|
482
|
+
) : (
|
|
483
|
+
<div className={cn("w-full", s.width)}>
|
|
484
|
+
{/* Render View Controls Header */}
|
|
485
|
+
<div className={cn("flex justify-center items-center mb-4 relative")}>
|
|
486
|
+
<div className={cn("font-semibold", s.headerTitle)}>
|
|
487
|
+
{view === 'months' ? currentMonth.getFullYear() : `${currentMonth.getFullYear() - (currentMonth.getFullYear() % 12)} - ${currentMonth.getFullYear() - (currentMonth.getFullYear() % 12) + 11}`}
|
|
488
|
+
</div>
|
|
489
|
+
{/* Navigation for views */}
|
|
490
|
+
<div className="absolute inset-0 flex justify-between items-center">
|
|
491
|
+
<button onClick={navigatePrev} className={cn("p-1 rounded-md border hover:bg-accent", s.navBtn, "flex items-center justify-center")}>
|
|
492
|
+
<svg className={s.icon} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /></svg>
|
|
493
|
+
</button>
|
|
494
|
+
<button onClick={navigateNext} className={cn("p-1 rounded-md border hover:bg-accent", s.navBtn, "flex items-center justify-center")}>
|
|
495
|
+
<svg className={s.icon} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /></svg>
|
|
496
|
+
</button>
|
|
497
|
+
</div>
|
|
498
|
+
</div>
|
|
499
|
+
|
|
500
|
+
<div className="h-[240px]">
|
|
501
|
+
{view === 'months' && renderMonths()}
|
|
502
|
+
{view === 'years' && renderYears()}
|
|
503
|
+
</div>
|
|
504
|
+
</div>
|
|
505
|
+
)}
|
|
506
|
+
</div>
|
|
507
|
+
</div>
|
|
508
|
+
)
|
|
509
|
+
}
|
|
510
|
+
))
|
|
511
|
+
|
|
512
|
+
Calendar.displayName = "Calendar"
|
|
513
|
+
|
|
514
|
+
export { Calendar }
|